Skip to content

Commit cea1aac

Browse files
Aaron K. Clarkclaude
andcommitted
feat(web): time tracking screen
Log time against a client (and optional job) with a manual date + start/end form; recent-entries table with durations. Wired to POST /v1/timeentry and /v1/timeentry/bycompany; cascading client->job selects; billable toggle. Builds clean. /web sub-project; root suite unaffected. Patch bump to 1.0.12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7684394 commit cea1aac

7 files changed

Lines changed: 176 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.12] - 2026-06-22
11+
12+
### Added
13+
- **web: Time tracking.** Log time against a client (and optionally a job)
14+
with a manual date + start/end form, and see a recent-entries table with
15+
durations. Wired to `POST /v1/timeentry` and
16+
`/v1/timeentry/bycompany`. Client→job selects cascade; billable toggle.
17+
Builds clean.
18+
1019
## [1.0.11] - 2026-06-22
1120

1221
### Added
@@ -546,7 +555,8 @@ original SQL Server database to this Node.js + PostgreSQL API.
546555

547556
---
548557

549-
[Unreleased]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.11...HEAD
558+
[Unreleased]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.12...HEAD
559+
[1.0.12]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.11...v1.0.12
550560
[1.0.11]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.10...v1.0.11
551561
[1.0.10]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.9...v1.0.10
552562
[1.0.9]: https://github.com/CryptoJones/TimeTrackerAPI/compare/v1.0.8...v1.0.9

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "TimeTrackerAPI",
3-
"version": "1.0.11",
3+
"version": "1.0.12",
44
"description": "Open-source Node.js + PostgreSQL time-tracking REST API with customer/company-scoped auth, OpenAPI spec, idempotency, and CSV export.",
55
"main": "server.js",
66
"keywords": [

web/src/App.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Signup from './pages/Signup.jsx';
77
import Dashboard from './pages/Dashboard.jsx';
88
import Clients from './pages/Clients.jsx';
99
import CustomerDetail from './pages/CustomerDetail.jsx';
10+
import TimeTracking from './pages/TimeTracking.jsx';
1011

1112
function Protected({ children }) {
1213
const { user, loading } = useAuth();
@@ -23,6 +24,7 @@ function Header() {
2324
<Link to="/" className="brand">⏱ TimeTracker</Link>
2425
<nav className="nav">
2526
<NavLink to="/" end>Dashboard</NavLink>
27+
<NavLink to="/time">Time</NavLink>
2628
<NavLink to="/clients">Clients</NavLink>
2729
</nav>
2830
<div className="spacer" />
@@ -43,6 +45,7 @@ export default function App() {
4345
<Route path="/login" element={<Login />} />
4446
<Route path="/signup" element={<Signup />} />
4547
<Route path="/" element={<Protected><Dashboard /></Protected>} />
48+
<Route path="/time" element={<Protected><TimeTracking /></Protected>} />
4649
<Route path="/clients" element={<Protected><Clients /></Protected>} />
4750
<Route path="/clients/:id" element={<Protected><CustomerDetail /></Protected>} />
4851
<Route path="*" element={<Navigate to="/" replace />} />

web/src/api.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,8 @@ export const createCustomer = (data) => api('/v1/customer', { method: 'POST', bo
7777
// --- jobs ---
7878
export const listJobs = (custId) => api(`/v1/job/bycustomer/${custId}`);
7979
export const createJob = (data) => api('/v1/job', { method: 'POST', body: data });
80+
81+
// --- time entries ---
82+
export const listTimeEntries = (companyId, query = '') =>
83+
api(`/v1/timeentry/bycompany/${companyId}${query}`);
84+
export const createTimeEntry = (data) => api('/v1/timeentry', { method: 'POST', body: data });

web/src/pages/TimeTracking.jsx

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
import { useEffect, useState, useCallback } from 'react';
4+
import { useAuth } from '../auth.jsx';
5+
import { listCustomers, listJobs, listTimeEntries, createTimeEntry, listFrom } from '../api.js';
6+
7+
function toIso(date, time) {
8+
if (!date || !time) return null;
9+
const d = new Date(`${date}T${time}:00`);
10+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
11+
}
12+
const today = () => new Date().toISOString().slice(0, 10);
13+
function fmtMins(m) {
14+
if (m == null) return '—';
15+
return `${Math.floor(m / 60)}h ${m % 60}m`;
16+
}
17+
18+
export default function TimeTracking() {
19+
const { user } = useAuth();
20+
const companyId = user?.companyId;
21+
const [customers, setCustomers] = useState([]);
22+
const [jobs, setJobs] = useState([]);
23+
const [entries, setEntries] = useState([]);
24+
const [loading, setLoading] = useState(true);
25+
const [error, setError] = useState(null);
26+
const [busy, setBusy] = useState(false);
27+
const [form, setForm] = useState({
28+
teCustId: '', teJobId: '', date: today(), start: '09:00', end: '10:00',
29+
teDescription: '', teBillable: true,
30+
});
31+
32+
const loadEntries = useCallback(async () => {
33+
if (!companyId) return;
34+
setEntries(listFrom(await listTimeEntries(companyId)));
35+
}, [companyId]);
36+
37+
useEffect(() => {
38+
let live = true;
39+
(async () => {
40+
if (!companyId) return;
41+
setLoading(true);
42+
try {
43+
setCustomers(listFrom(await listCustomers(companyId)));
44+
await loadEntries();
45+
if (live) setError(null);
46+
} catch (err) {
47+
if (live) setError(err.message);
48+
} finally {
49+
if (live) setLoading(false);
50+
}
51+
})();
52+
return () => { live = false; };
53+
}, [companyId, loadEntries]);
54+
55+
// Load the selected client's jobs.
56+
useEffect(() => {
57+
let live = true;
58+
(async () => {
59+
if (!form.teCustId) { setJobs([]); return; }
60+
try {
61+
const js = listFrom(await listJobs(Number(form.teCustId)));
62+
if (live) setJobs(js);
63+
} catch {
64+
if (live) setJobs([]);
65+
}
66+
})();
67+
return () => { live = false; };
68+
}, [form.teCustId]);
69+
70+
const set = (k) => (e) =>
71+
setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
72+
73+
async function onLog(e) {
74+
e.preventDefault();
75+
setError(null);
76+
setBusy(true);
77+
try {
78+
const teStartedAt = toIso(form.date, form.start);
79+
const teEndedAt = toIso(form.date, form.end);
80+
if (!teStartedAt || !teEndedAt) throw new Error('Enter a valid date, start, and end time.');
81+
const body = { teCustId: Number(form.teCustId), teStartedAt, teEndedAt, teBillable: form.teBillable };
82+
if (form.teJobId) body.teJobId = Number(form.teJobId);
83+
if (form.teDescription) body.teDescription = form.teDescription;
84+
await createTimeEntry(body);
85+
setForm((f) => ({ ...f, teDescription: '' }));
86+
await loadEntries();
87+
} catch (err) {
88+
setError(err.message);
89+
} finally {
90+
setBusy(false);
91+
}
92+
}
93+
94+
const custName = (id) => {
95+
const c = customers.find((x) => x.custId === id);
96+
return c ? (c.custCompanyName || [c.custFName, c.custLName].filter(Boolean).join(' ')) : `#${id}`;
97+
};
98+
99+
return (
100+
<div>
101+
<h1>Time tracking</h1>
102+
<div className="card" style={{ marginBottom: 20 }}>
103+
<h2>Log time</h2>
104+
<form onSubmit={onLog} className="row-form">
105+
<select value={form.teCustId} onChange={set('teCustId')} required>
106+
<option value="">Select client…</option>
107+
{customers.map((c) => (
108+
<option key={c.custId} value={c.custId}>
109+
{c.custCompanyName || [c.custFName, c.custLName].filter(Boolean).join(' ')}
110+
</option>
111+
))}
112+
</select>
113+
<select value={form.teJobId} onChange={set('teJobId')}>
114+
<option value="">(no job)</option>
115+
{jobs.map((j) => <option key={j.jobId} value={j.jobId}>{j.jobDesc}</option>)}
116+
</select>
117+
<input type="date" value={form.date} onChange={set('date')} required />
118+
<input type="time" value={form.start} onChange={set('start')} required />
119+
<input type="time" value={form.end} onChange={set('end')} required />
120+
<input placeholder="Notes (optional)" value={form.teDescription} onChange={set('teDescription')} />
121+
<label className="inline"><input type="checkbox" checked={form.teBillable} onChange={set('teBillable')} /> Billable</label>
122+
<button disabled={busy} type="submit">{busy ? 'Logging…' : 'Log time'}</button>
123+
</form>
124+
</div>
125+
126+
{error && <p className="error">{error}</p>}
127+
{loading ? <p className="muted">Loading…</p> : (
128+
<table className="table">
129+
<thead><tr><th>Date</th><th>Client</th><th>Duration</th><th>Billable</th><th>Notes</th></tr></thead>
130+
<tbody>
131+
{entries.length === 0 && <tr><td colSpan={5} className="muted">No time logged yet.</td></tr>}
132+
{entries.map((t) => (
133+
<tr key={t.teId}>
134+
<td>{t.teStartedAt ? t.teStartedAt.slice(0, 10) : '—'}</td>
135+
<td>{custName(t.teCustId)}</td>
136+
<td>{fmtMins(t.teMinutes)}</td>
137+
<td className="muted">{t.teBillable ? 'Yes' : 'No'}</td>
138+
<td className="muted">{t.teDescription || '—'}</td>
139+
</tr>
140+
))}
141+
</tbody>
142+
</table>
143+
)}
144+
</div>
145+
);
146+
}

web/src/styles.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,11 @@ button.ghost:hover { background: #20242d; }
8383
.table th { color: var(--muted); font-weight: 600; font-size: 13px; }
8484
.table tr:hover td { background: #14171d; }
8585
.table a { color: var(--accent-2); text-decoration: none; }
86+
87+
select {
88+
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
89+
background: #0d0f13; color: var(--text); font-size: 15px;
90+
}
91+
select:focus { outline: 2px solid var(--accent-2); border-color: transparent; }
92+
label.inline { flex-direction: row; align-items: center; gap: 6px; color: var(--text); font-size: 14px; }
93+
label.inline input { flex: 0 0 auto; }

0 commit comments

Comments
 (0)