Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev-dist/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"), {
Expand Down
52 changes: 38 additions & 14 deletions src/components/ArchiveItem.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -118,6 +118,30 @@ export const ArchiveItem: React.FC<ArchiveItemProps> = ({ day, onEdit }) => {
</div>
</div>

{/* Daily Summary */}
{(() => {
const descriptions = day.tasks
.filter((task) => task.description)
.map((task) => task.description!);
const summary = generateDailySummary(descriptions);

if (!summary) return null;

return (
<div className="space-y-2 border-t pt-4">
<h4 className="font-medium text-gray-900 flex items-center mb-2">
<FileText className="w-4 h-4 mr-2" />
Daily Summary
</h4>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-md print:bg-white print:border print:border-gray-300">
<p className="text-sm text-gray-700 dark:text-gray-300 print:text-gray-800 leading-relaxed">
{summary}
</p>
</div>
</div>
);
})()}

{/* Tasks Table */}
<div className="print:mt-2">
<h4 className="font-medium text-gray-900 print:hidden mb-2">
Expand Down
125 changes: 86 additions & 39 deletions src/contexts/TimeTrackingContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(','));
}
Expand All @@ -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: {
Expand All @@ -1103,7 +1126,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
endDate || new Date()
)
},
days: filteredDays,
days: daysWithSummary,
projects: projects
};

Expand All @@ -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: {
Expand Down Expand Up @@ -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
};
};

Expand Down
50 changes: 46 additions & 4 deletions src/utils/timeUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");
};
Loading