From bf2a1816db678a44cd3836d73d859912a46bb3ce Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Mon, 19 Jan 2026 15:18:34 -0700 Subject: [PATCH] Added custom ranges for report filtering + CSV exports --- package.json | 2 +- src/pages/api/reports/export.ts | 137 ++++++++++++++++++++++++++++++ src/pages/dashboard/reports.astro | 61 +++++++++++-- 3 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/pages/api/reports/export.ts diff --git a/package.json b/package.json index 4ebda8f..dcbac49 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronus", "type": "module", - "version": "2.1.0", + "version": "2.2.0", "scripts": { "dev": "astro dev", "build": "astro build", diff --git a/src/pages/api/reports/export.ts b/src/pages/api/reports/export.ts new file mode 100644 index 0000000..260e155 --- /dev/null +++ b/src/pages/api/reports/export.ts @@ -0,0 +1,137 @@ +import type { APIRoute } from 'astro'; +import { db } from '../../../db'; +import { timeEntries, members, users, clients, categories } from '../../../db/schema'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; + +export const GET: APIRoute = async ({ request, locals, cookies }) => { + const user = locals.user; + if (!user) { + return new Response('Unauthorized', { status: 401 }); + } + + // Get current team from cookie + const currentTeamId = cookies.get('currentTeamId')?.value; + + const userMemberships = await db.select() + .from(members) + .where(eq(members.userId, user.id)) + .all(); + + if (userMemberships.length === 0) { + return new Response('No organization found', { status: 404 }); + } + + // Use current team or fallback to first membership + const userMembership = currentTeamId + ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] + : userMemberships[0]; + + const url = new URL(request.url); + const selectedMemberId = url.searchParams.get('member') || ''; + const selectedCategoryId = url.searchParams.get('category') || ''; + const selectedClientId = url.searchParams.get('client') || ''; + const timeRange = url.searchParams.get('range') || 'week'; + const customFrom = url.searchParams.get('from'); + const customTo = url.searchParams.get('to'); + + const now = new Date(); + let startDate = new Date(); + let endDate = new Date(); + + switch (timeRange) { + case 'today': + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + break; + case 'week': + startDate.setDate(now.getDate() - 7); + break; + case 'month': + startDate.setMonth(now.getMonth() - 1); + break; + case 'mtd': + startDate = new Date(now.getFullYear(), now.getMonth(), 1); + break; + case 'ytd': + startDate = new Date(now.getFullYear(), 0, 1); + break; + case 'last-month': + startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); + endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); + break; + case 'custom': + if (customFrom) { + const parts = customFrom.split('-'); + startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0); + } + if (customTo) { + const parts = customTo.split('-'); + endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999); + } + break; + } + + const conditions = [ + eq(timeEntries.organizationId, userMembership.organizationId), + gte(timeEntries.startTime, startDate), + lte(timeEntries.startTime, endDate), + ]; + + if (selectedMemberId) { + conditions.push(eq(timeEntries.userId, selectedMemberId)); + } + + if (selectedCategoryId) { + conditions.push(eq(timeEntries.categoryId, selectedCategoryId)); + } + + if (selectedClientId) { + conditions.push(eq(timeEntries.clientId, selectedClientId)); + } + + const entries = await db.select({ + entry: timeEntries, + user: users, + client: clients, + category: categories, + }) + .from(timeEntries) + .innerJoin(users, eq(timeEntries.userId, users.id)) + .innerJoin(clients, eq(timeEntries.clientId, clients.id)) + .innerJoin(categories, eq(timeEntries.categoryId, categories.id)) + .where(and(...conditions)) + .orderBy(desc(timeEntries.startTime)) + .all(); + + // Generate CSV + const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description']; + const rows = entries.map(e => { + const start = e.entry.startTime; + const end = e.entry.endTime; + + let duration = 0; + if (end) { + duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours + } + + return [ + start.toLocaleDateString(), + start.toLocaleTimeString(), + end ? end.toLocaleTimeString() : '', + end ? duration.toFixed(2) : 'Running', + `"${(e.user.name || '').replace(/"/g, '""')}"`, + `"${(e.client.name || '').replace(/"/g, '""')}"`, + `"${(e.category.name || '').replace(/"/g, '""')}"`, + `"${(e.entry.description || '').replace(/"/g, '""')}"` + ].join(','); + }); + + const csvContent = [headers.join(','), ...rows].join('\n'); + + return new Response(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`, + }, + }); +}; diff --git a/src/pages/dashboard/reports.astro b/src/pages/dashboard/reports.astro index e00c7a0..bff9402 100644 --- a/src/pages/dashboard/reports.astro +++ b/src/pages/dashboard/reports.astro @@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || ''; const selectedCategoryId = url.searchParams.get('category') || ''; const selectedClientId = url.searchParams.get('client') || ''; const timeRange = url.searchParams.get('range') || 'week'; +const customFrom = url.searchParams.get('from'); +const customTo = url.searchParams.get('to'); const now = new Date(); let startDate = new Date(); @@ -78,6 +80,16 @@ switch (timeRange) { startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); break; + case 'custom': + if (customFrom) { + const parts = customFrom.split('-'); + startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0); + } + if (customTo) { + const parts = customTo.split('-'); + endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999); + } + break; } const conditions = [ @@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) { case 'mtd': return 'Month to Date'; case 'ytd': return 'Year to Date'; case 'last-month': return 'Last Month'; + case 'custom': return 'Custom Range'; default: return 'Last 7 Days'; } } @@ -273,9 +286,39 @@ function getTimeRangeLabel(range: string) { + + {timeRange === 'custom' && ( + <> +
+ + +
+
+ + +
+ + )} +