Loading
Create a time tracking application with start/stop timers, project categories, daily and weekly reports, chart visualization, and CSV export.
Time tracking is one of the most practical skills to learn as a developer because the concepts transfer directly to building any productivity tool. In this tutorial, you will build a time tracking app with React and TypeScript that features a start/stop timer, project-based categories, daily and weekly summary reports, a bar chart visualization, and CSV export for your records.
Everything stores in localStorage, so there is no backend required. The app is a single-page React application built with Vite that you can deploy anywhere as a static site. This tutorial is designed for beginners, with each step building on the previous one and introducing one new concept at a time.
By the end, you will have a genuinely useful tool for tracking how you spend your time, plus a solid understanding of React state management, localStorage persistence, and rendering charts with pure SVG.
Create the project:
Replace src/index.css with a clean foundation:
Create the type definitions in src/types.ts:
Create src/hooks/useLocalStorage.ts:
Create src/components/Timer.tsx:
Create src/components/EntryList.tsx:
Create src/components/BarChart.tsx using pure SVG:
Create src/components/WeeklyReport.tsx:
Create src/utils/export.ts:
Update src/App.tsx to bring everything together:
Run the application:
Open the browser and you have a fully functional time tracker. Select a project, type a description, and hit Start. When you stop the timer, the entry is saved to localStorage and appears in your daily list. Switch to the Report view to see bar charts of your daily hours and per-project breakdown for the week. Click Export to download all entries as a CSV file.
The app persists across browser sessions and works entirely offline. For next steps, consider adding a project management screen where you can create and customize project colors, a calendar view for browsing past weeks, keyboard shortcuts for quickly starting and stopping the timer, and dark mode support with a CSS media query.
npm create vite@latest time-tracker -- --template react-ts
cd time-tracker
npm install* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
line-height: 1.6;
}
button {
cursor: pointer;
border: none;
font: inherit;
}
input,
select {
font: inherit;
}export interface TimeEntry {
id: string;
project: string;
description: string;
startTime: string;
endTime: string;
durationMs: number;
}
export interface Project {
name: string;
color: string;
}
export interface TimerState {
isRunning: boolean;
project: string;
description: string;
startTime: string | null;
}import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Failed to save ${key} to localStorage:`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}import React, { useState, useEffect, useRef } from "react";
import type { Project, TimerState, TimeEntry } from "../types";
interface TimerProps {
projects: Project[];
onComplete: (entry: TimeEntry) => void;
}
function formatElapsed(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [
hours.toString().padStart(2, "0"),
minutes.toString().padStart(2, "0"),
seconds.toString().padStart(2, "0"),
].join(":");
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
export function Timer({ projects, onComplete }: TimerProps): React.ReactElement {
const [timer, setTimer] = useState<TimerState>({
isRunning: false,
project: projects[0]?.name ?? "",
description: "",
startTime: null,
});
const [elapsed, setElapsed] = useState<number>(0);
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
if (timer.isRunning && timer.startTime) {
intervalRef.current = setInterval(() => {
setElapsed(Date.now() - new Date(timer.startTime!).getTime());
}, 1000);
} else {
setElapsed(0);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [timer.isRunning, timer.startTime]);
function handleStart(): void {
if (!timer.project) return;
setTimer((prev) => ({
...prev,
isRunning: true,
startTime: new Date().toISOString(),
}));
}
function handleStop(): void {
if (!timer.startTime) return;
const endTime = new Date().toISOString();
const durationMs = new Date(endTime).getTime() - new Date(timer.startTime).getTime();
const entry: TimeEntry = {
id: generateId(),
project: timer.project,
description: timer.description,
startTime: timer.startTime,
endTime,
durationMs,
};
onComplete(entry);
setTimer({
isRunning: false,
project: timer.project,
description: "",
startTime: null,
});
}
return (
<div style={{
background: "white",
borderRadius: 16,
padding: 32,
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
border: "1px solid #e2e8f0",
marginBottom: 24,
}}>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<div style={{
fontSize: 48,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
color: timer.isRunning ? "#6366f1" : "#1e293b",
}}>
{formatElapsed(elapsed)}
</div>
</div>
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<select
value={timer.project}
onChange={(e) => setTimer((prev) => ({ ...prev, project: e.target.value }))}
disabled={timer.isRunning}
style={{
flex: 1,
padding: "10px 14px",
borderRadius: 8,
border: "1px solid #e2e8f0",
background: "white",
}}
>
{projects.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
<input
value={timer.description}
onChange={(e) => setTimer((prev) => ({ ...prev, description: e.target.value }))}
placeholder="What are you working on?"
disabled={timer.isRunning}
style={{
flex: 2,
padding: "10px 14px",
borderRadius: 8,
border: "1px solid #e2e8f0",
}}
/>
</div>
<button
onClick={timer.isRunning ? handleStop : handleStart}
style={{
width: "100%",
padding: "14px",
borderRadius: 10,
fontSize: 16,
fontWeight: 600,
color: "white",
background: timer.isRunning ? "#ef4444" : "#6366f1",
transition: "background 150ms",
}}
>
{timer.isRunning ? "Stop" : "Start"}
</button>
</div>
);
}import React from "react";
import type { TimeEntry, Project } from "../types";
interface EntryListProps {
entries: TimeEntry[];
projects: Project[];
onDelete: (id: string) => void;
}
function formatDuration(ms: number): string {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatTime(isoString: string): string {
return new Date(isoString).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
export function EntryList({ entries, projects, onDelete }: EntryListProps): React.ReactElement {
const projectColors = new Map(projects.map((p) => [p.name, p.color]));
const today = new Date().toDateString();
const todayEntries = entries.filter(
(e) => new Date(e.startTime).toDateString() === today
);
const totalToday = todayEntries.reduce((sum, e) => sum + e.durationMs, 0);
return (
<div style={{
background: "white",
borderRadius: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
border: "1px solid #e2e8f0",
marginBottom: 24,
}}>
<div style={{
padding: "16px 24px",
borderBottom: "1px solid #e2e8f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<h2 style={{ fontSize: 16, fontWeight: 600 }}>Today</h2>
<span style={{ fontSize: 14, color: "#6366f1", fontWeight: 600 }}>
{formatDuration(totalToday)}
</span>
</div>
{todayEntries.length === 0 ? (
<p style={{ padding: 24, textAlign: "center", color: "#94a3b8" }}>
No entries today. Start the timer to begin tracking.
</p>
) : (
todayEntries.map((entry) => (
<div
key={entry.id}
style={{
display: "flex",
alignItems: "center",
padding: "12px 24px",
borderBottom: "1px solid #f1f5f9",
gap: 12,
}}
>
<div
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: projectColors.get(entry.project) ?? "#94a3b8",
flexShrink: 0,
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>{entry.description || "No description"}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{entry.project} · {formatTime(entry.startTime)} - {formatTime(entry.endTime)}
</div>
</div>
<div style={{ fontWeight: 600, fontSize: 14, color: "#475569", marginRight: 12 }}>
{formatDuration(entry.durationMs)}
</div>
<button
onClick={() => onDelete(entry.id)}
style={{
background: "none",
color: "#cbd5e1",
fontSize: 16,
padding: "4px 8px",
}}
>
x
</button>
</div>
))
)}
</div>
);
}import React from "react";
interface BarChartProps {
data: Array<{ label: string; value: number; color: string }>;
title: string;
}
export function BarChart({ data, title }: BarChartProps): React.ReactElement {
const maxValue = Math.max(...data.map((d) => d.value), 1);
const barWidth = 40;
const chartHeight = 200;
const chartWidth = data.length * (barWidth + 20) + 40;
return (
<div style={{
background: "white",
borderRadius: 16,
padding: 24,
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
border: "1px solid #e2e8f0",
marginBottom: 24,
}}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>{title}</h2>
<svg width="100%" viewBox={`0 0 ${chartWidth} ${chartHeight + 40}`}>
{data.map((item, index) => {
const barHeight = (item.value / maxValue) * chartHeight;
const x = 20 + index * (barWidth + 20);
const y = chartHeight - barHeight;
return (
<g key={item.label}>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={4}
fill={item.color}
opacity={0.85}
/>
<text
x={x + barWidth / 2}
y={y - 8}
textAnchor="middle"
fontSize={11}
fill="#475569"
fontWeight={600}
>
{item.value > 0 ? `${Math.round(item.value / 60)}m` : ""}
</text>
<text
x={x + barWidth / 2}
y={chartHeight + 20}
textAnchor="middle"
fontSize={10}
fill="#94a3b8"
>
{item.label}
</text>
</g>
);
})}
</svg>
</div>
);
}import React from "react";
import { BarChart } from "./BarChart";
import type { TimeEntry, Project } from "../types";
interface WeeklyReportProps {
entries: TimeEntry[];
projects: Project[];
}
function getWeekDays(): Array<{ date: Date; label: string }> {
const today = new Date();
const dayOfWeek = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() - ((dayOfWeek + 6) % 7));
const days: Array<{ date: Date; label: string }> = [];
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
for (let i = 0; i < 7; i++) {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
days.push({ date, label: dayNames[i] });
}
return days;
}
export function WeeklyReport({ entries, projects }: WeeklyReportProps): React.ReactElement {
const weekDays = getWeekDays();
const projectColors = new Map(projects.map((p) => [p.name, p.color]));
const dailyData = weekDays.map((day) => {
const dayEntries = entries.filter(
(e) => new Date(e.startTime).toDateString() === day.date.toDateString()
);
const totalMinutes = dayEntries.reduce((sum, e) => sum + e.durationMs / 60000, 0);
return {
label: day.label,
value: Math.round(totalMinutes),
color: "#6366f1",
};
});
const projectTotals = new Map<string, number>();
const weekStart = weekDays[0].date;
const weekEnd = new Date(weekDays[6].date);
weekEnd.setHours(23, 59, 59, 999);
entries
.filter((e) => {
const d = new Date(e.startTime);
return d >= weekStart && d <= weekEnd;
})
.forEach((e) => {
const current = projectTotals.get(e.project) ?? 0;
projectTotals.set(e.project, current + e.durationMs);
});
const projectData = Array.from(projectTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([name, ms]) => ({
label: name.slice(0, 8),
value: Math.round(ms / 60000),
color: projectColors.get(name) ?? "#94a3b8",
}));
const totalWeekMs = Array.from(projectTotals.values()).reduce((a, b) => a + b, 0);
const totalHours = Math.floor(totalWeekMs / 3600000);
const totalMinutes = Math.floor((totalWeekMs % 3600000) / 60000);
return (
<div>
<div style={{
background: "white",
borderRadius: 16,
padding: 24,
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
border: "1px solid #e2e8f0",
marginBottom: 24,
textAlign: "center",
}}>
<p style={{ color: "#94a3b8", fontSize: 14 }}>This Week</p>
<p style={{ fontSize: 36, fontWeight: 700, color: "#6366f1" }}>
{totalHours}h {totalMinutes}m
</p>
</div>
<BarChart data={dailyData} title="Daily Hours" />
{projectData.length > 0 && <BarChart data={projectData} title="By Project" />}
</div>
);
}import type { TimeEntry } from "../types";
function formatDate(isoString: string): string {
return new Date(isoString).toLocaleDateString();
}
function formatTime(isoString: string): string {
return new Date(isoString).toLocaleTimeString();
}
function formatDurationHours(ms: number): string {
return (ms / 3600000).toFixed(2);
}
export function exportToCSV(entries: TimeEntry[]): void {
const header = "Date,Project,Description,Start,End,Duration (hours)\n";
const rows = entries
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
.map((e) =>
[
formatDate(e.startTime),
`"${e.project}"`,
`"${e.description}"`,
formatTime(e.startTime),
formatTime(e.endTime),
formatDurationHours(e.durationMs),
].join(",")
)
.join("\n");
const csv = header + rows;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `time-entries-${Date.now()}.csv`;
link.click();
URL.revokeObjectURL(url);
}import React, { useState } from "react";
import { Timer } from "./components/Timer";
import { EntryList } from "./components/EntryList";
import { WeeklyReport } from "./components/WeeklyReport";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { exportToCSV } from "./utils/export";
import type { TimeEntry, Project } from "./types";
const DEFAULT_PROJECTS: Project[] = [
{ name: "Development", color: "#6366f1" },
{ name: "Design", color: "#ec4899" },
{ name: "Meetings", color: "#f59e0b" },
{ name: "Research", color: "#10b981" },
{ name: "Admin", color: "#64748b" },
];
type View = "timer" | "report";
export default function App(): React.ReactElement {
const [entries, setEntries] = useLocalStorage<TimeEntry[]>("time-entries", []);
const [projects] = useLocalStorage<Project[]>("time-projects", DEFAULT_PROJECTS);
const [view, setView] = useState<View>("timer");
function handleComplete(entry: TimeEntry): void {
setEntries((prev) => [entry, ...prev]);
}
function handleDelete(id: string): void {
setEntries((prev) => prev.filter((e) => e.id !== id));
}
return (
<div style={{ maxWidth: 640, margin: "0 auto", padding: "32px 16px" }}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 32,
}}>
<h1 style={{ fontSize: 24, fontWeight: 700 }}>Time Tracker</h1>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => setView("timer")}
style={{
padding: "8px 16px",
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: view === "timer" ? "#6366f1" : "#f1f5f9",
color: view === "timer" ? "white" : "#475569",
}}
>
Timer
</button>
<button
onClick={() => setView("report")}
style={{
padding: "8px 16px",
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: view === "report" ? "#6366f1" : "#f1f5f9",
color: view === "report" ? "white" : "#475569",
}}
>
Report
</button>
<button
onClick={() => exportToCSV(entries)}
style={{
padding: "8px 16px",
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
background: "#f1f5f9",
color: "#475569",
}}
>
Export
</button>
</div>
</div>
{view === "timer" ? (
<>
<Timer projects={projects} onComplete={handleComplete} />
<EntryList entries={entries} projects={projects} onDelete={handleDelete} />
</>
) : (
<WeeklyReport entries={entries} projects={projects} />
)}
</div>
);
}npm run dev