diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 8f7d9fd..bc3d9a9 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -79,7 +79,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; */ workbox.precacheAndRoute([{ "url": "index.html", - "revision": "0.puuvtj6d5ts" + "revision": "0.qebr9bqg7as" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/components/ArchiveItem.tsx b/src/components/ArchiveItem.tsx index 0d50bca..63097c8 100644 --- a/src/components/ArchiveItem.tsx +++ b/src/components/ArchiveItem.tsx @@ -1,18 +1,18 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from '@/components/ui/table'; -import { Calendar, Clock, Edit, RotateCcw } from 'lucide-react'; -import { formatDuration, formatDurationLong, formatTime, formatDate } from '@/utils/timeUtil'; -import { DayRecord } from '@/contexts/TimeTrackingContext'; -import { useTimeTracking } from '@/hooks/useTimeTracking'; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Calendar, Clock, Edit, RotateCcw, FileText } from "lucide-react"; +import { formatDuration, formatDurationLong, formatTime, formatDate, generateDailySummary } from "@/utils/timeUtil"; +import { DayRecord } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; interface ArchiveItemProps { day: DayRecord; @@ -118,6 +118,30 @@ export const ArchiveItem: React.FC = ({ day, onEdit }) => { + {/* Daily Summary */} + {(() => { + const descriptions = day.tasks + .filter((task) => task.description) + .map((task) => task.description!); + const summary = generateDailySummary(descriptions); + + if (!summary) return null; + + return ( +
+

+ + Daily Summary +

+
+

+ {summary} +

+
+
+ ); + })()} + {/* Tasks Table */}

diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index ee5f2db..a361d38 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -1,16 +1,17 @@ import React, { - createContext, - useContext, - useState, - useEffect, - useCallback, - useRef -} from 'react'; -import { DEFAULT_CATEGORIES, TaskCategory } from '@/config/categories'; -import { DEFAULT_PROJECTS, ProjectCategory } from '@/config/projects'; -import { useAuth } from '@/hooks/useAuth'; -import { createDataService, DataService } from '@/services/dataService'; -import { useRealtimeSync } from '@/hooks/useRealtimeSync'; + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef +} from "react"; +import { DEFAULT_CATEGORIES, TaskCategory } from "@/config/categories"; +import { DEFAULT_PROJECTS, ProjectCategory } from "@/config/projects"; +import { useAuth } from "@/hooks/useAuth"; +import { createDataService, DataService } from "@/services/dataService"; +import { useRealtimeSync } from "@/hooks/useRealtimeSync"; +import { generateDailySummary } from "@/utils/timeUtil"; export interface Task { id: string; @@ -58,14 +59,15 @@ export interface TimeEntry { } export interface InvoiceData { - client: string; - period: { startDate: Date; endDate: Date }; - projects: { [key: string]: { hours: number; rate: number; amount: number } }; - summary: { - totalHours: number; - totalAmount: number; - }; - tasks: Task[]; + client: string; + period: { startDate: Date; endDate: Date }; + projects: { [key: string]: { hours: number; rate: number; amount: number } }; + summary: { + totalHours: number; + totalAmount: number; + }; + tasks: (Task & { dayId: string; dayDate: string; dailySummary: string })[]; + dailySummaries: { [dayId: string]: { date: string; summary: string } }; } interface TimeTrackingContextType { @@ -1032,11 +1034,18 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ 'day_record_id', 'is_current', 'inserted_at', - 'updated_at' + 'updated_at', + 'daily_summary' ]; const rows = [headers.join(',')]; filteredDays.forEach((day) => { + // Generate daily summary once per day + const dayDescriptions = day.tasks + .filter((t) => t.description) + .map((t) => t.description!); + const dailySummary = generateDailySummary(dayDescriptions); + day.tasks.forEach((task) => { if (task.duration) { const project = projects.find((p) => p.name === task.project); @@ -1066,7 +1075,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ `"${day.id}"`, // day_record_id 'false', // is_current - archived tasks are not current `"${insertedAtISO}"`, // inserted_at - actual database timestamp - `"${updatedAtISO}"` // updated_at - actual database timestamp + `"${updatedAtISO}"`, // updated_at - actual database timestamp + `"${dailySummary.replace(/"/g, '""')}"` // daily_summary - escape quotes for CSV ]; rows.push(row.join(',')); } @@ -1086,6 +1096,19 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }); } + // Add daily summary to each day + const daysWithSummary = filteredDays.map((day) => { + const dayDescriptions = day.tasks + .filter((t) => t.description) + .map((t) => t.description!); + const dailySummary = generateDailySummary(dayDescriptions); + + return { + ...day, + dailySummary + }; + }); + const exportData = { exportDate: new Date().toISOString(), period: { @@ -1103,7 +1126,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ endDate || new Date() ) }, - days: filteredDays, + days: daysWithSummary, projects: projects }; @@ -1124,26 +1147,49 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ return dayDate >= startDate && dayDate <= endDate; }); + // Generate daily summaries for all days in the period + const dailySummaries: { [dayId: string]: { date: string; summary: string } } = {}; + filteredDays.forEach((day) => { + const dayDescriptions = day.tasks + .filter((t) => t.description) + .map((t) => t.description!); + const summary = generateDailySummary(dayDescriptions); + + if (summary) { + dailySummaries[day.id] = { + date: day.date, + summary + }; + } + }); + const clientTasks = filteredDays.flatMap((day) => - day.tasks.filter((task) => { - if (!task.client || task.client !== clientName || !task.duration) { - return false; - } + day.tasks + .filter((task) => { + if (!task.client || task.client !== clientName || !task.duration) { + return false; + } - // Only include billable tasks in invoices - if (task.project && task.category) { - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); + // Only include billable tasks in invoices + if (task.project && task.category) { + const project = projectMap.get(task.project); + const category = categoryMap.get(task.category); - const projectIsBillable = project?.isBillable !== false; - const categoryIsBillable = category?.isBillable !== false; + const projectIsBillable = project?.isBillable !== false; + const categoryIsBillable = category?.isBillable !== false; - // Task must be billable to appear on invoice - return projectIsBillable && categoryIsBillable; - } + // Task must be billable to appear on invoice + return projectIsBillable && categoryIsBillable; + } - return false; - }) + return false; + }) + .map((task) => ({ + ...task, + dayId: day.id, + dayDate: day.date, + dailySummary: dailySummaries[day.id]?.summary || "" + })) ); const projectSummary: { @@ -1181,7 +1227,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ totalHours: Math.round(totalHours * 100) / 100, totalAmount: Math.round(totalAmount * 100) / 100 }, - tasks: clientTasks + tasks: clientTasks, + dailySummaries }; }; diff --git a/src/utils/timeUtil.ts b/src/utils/timeUtil.ts index f649970..6f28cae 100644 --- a/src/utils/timeUtil.ts +++ b/src/utils/timeUtil.ts @@ -55,9 +55,51 @@ export const formatHoursDecimal = (milliseconds: number): number => { }; export const calculateHourlyRate = ( - totalDuration: number, - rate: number + totalDuration: number, + rate: number ): number => { - const hours = formatHoursDecimal(totalDuration); - return Math.round(hours * rate * 100) / 100; + const hours = formatHoursDecimal(totalDuration); + return Math.round(hours * rate * 100) / 100; +}; + +/** + * Generates a readable summary paragraph from task descriptions + * @param descriptions - Array of task descriptions + * @returns A formatted paragraph combining all descriptions + */ +export const generateDailySummary = (descriptions: string[]): string => { + // Filter out empty or whitespace-only descriptions + const validDescriptions = descriptions + .filter((desc) => desc && desc.trim().length > 0) + .map((desc) => desc.trim()); + + if (validDescriptions.length === 0) { + return ""; + } + + // Connectors to use between sentences (not used for first sentence) + const connectors = ["Additionally,", "Also,", "Furthermore,", "Moreover,"]; + + // Format each description into a proper sentence + const formattedSentences = validDescriptions.map((desc, index) => { + // Capitalize first letter + let sentence = desc.charAt(0).toUpperCase() + desc.slice(1); + + // Add period at the end if missing punctuation + const lastChar = sentence.charAt(sentence.length - 1); + if (![".", "!", "?"].includes(lastChar)) { + sentence += "."; + } + + // Add connector for sentences after the first one (vary the connectors) + if (index > 0 && index < validDescriptions.length) { + const connectorIndex = (index - 1) % connectors.length; + sentence = `${connectors[connectorIndex]} ${sentence.charAt(0).toLowerCase()}${sentence.slice(1)}`; + } + + return sentence; + }); + + // Join all sentences with a space + return formattedSentences.join(" "); };