As a freelance software professional, I have to keep exact time of my work for different clients. When no existing SaaS solution was sufficient to my needs, I decided to take it as an opportunity for a side project, and developed my own time-tracking system. This post describes the process I went through and shares my technical path throughout the project.
For the past couple of years I’ve been working as a freelance software engineer and mentor; my clients hire me to mentor their engineers or engineering managers, to teach them sustainable engineering practices, and to help them deal with software that’s hard to change. I typically take my fee in form of a presold bank of hours at a discount from the nominal hourly fee, so it’s important for me to track every activity I perform with a client, be it a pair-programming session, a 15 minute code review, or a 7 minute phone call.
When I started my practice, I found Clockify as a usable solution for tracking my time. It has the notion of clients and projects, and one can easily enter activities either directly by choosing start and end times, or by starting and stopping a timer. One missing feature was tracking of a client’s pre/post-paid balance - how many hours I worked vs how many were paid for, and I had to manage these data separately in a different system.
Eventually I came to the conclusion that Clockify isn’t the solution for me; it aims at teams, it goes into too much details and I was mostly using a single feature. Realizing that no provider in the time tracking SaaS ecosystem caters to my exact needs, I decided to make the completely irrational choice of developing my own software - both to ease my life, and to serve as an interesting side project.
My technological guidelines were simple: use stuff I’ve had a good experience with, and try to learn something new. I wanted to minimize resources, so a serverless solution was needed, and I decided to go with Firebase which I previously used with a client in a React Native project, so my stack boiled down to Typescript, React, Material UI and Firebase. Not a bold choice, but a practical one. Initially I used Webpack for bundling and Jest for component testing, but after significant frustration from the fragmentation having to maintain separate Webpack / Jest configs, I decided to step out of my comfort zone and go with Vite and Vitest, having heard good things about them from some clients of mine. Finally, after hearing about it everywhere, I decided to give Cypress a try as an E2E test framework.
True to my teachings, I started with an E2E test: a Cypress test that logs in, creates a new client and adds a phone call activity. To make it pass I had to implement a comprehensive walking skeleton: Webpack serving a React app using Firebase Auth and Firestore. This took several hours over a couple of days, and resulted in a simple design: a single page application with 3 routes (using react-router) for the login pane, client list and client details pane. All data is fetched using hooks querying Firebase, where the Firebase instance is provided via a React Context.
The next feature was the timer; I wrote a component test rendering the entire app, creating a client, starting a timer, stopping it and expecting an activity to have been created. I spent a lot of time trying to get Firebase Web SDK v9 to work under the Jest jsdom environment, which was tricky due to several reasons. The first hassle was the fact that the Firebase SDK is shipped as ES modules, which Jest is unable to import. A lot of research led me to transform the Firebase modules using Babel, but then I ran into the second hassle: the Firebase SDK relies on the fetch API, while jsdom doesn’t natively provide it and no polyfill seemed to satisfy Firebase.
After wasting more than 10 hours I decided to give up and introduced a second implementation for all data hooks, using an in-memory store. I used another React Context for a pattern I refer to as the IO Context: a context that provides concrete implementation for data hooks, where each hook is implemented twice: once against Firebase and once against the memory store. In Jest, I provide the IOContext with the memory store hooks, and in Webpack I provide the Firebase hooks. The component test now became simpler: render an app with an existing client, start and stop the timer, and expect an activity to have been added to the memory store.
type ActivitiesHook = (id: Client["id"], limit: number) => {
lastActivities: Activity[];
loading: boolean;
error: Error | undefined;
}
type ClientsHook = () => {
addClient: (client: ClientFields) => Promise<Client["id"]>;
clients: Client[];
loading: boolean;
error: undefined | Error;
};
type TimerHook = () => {
timer: Timer | null;
startTimer: (clientId: Client["id"]) => void;
stopTimer: (timer: Timer) => void;
}
type IOContext = {
useActivities: ActivitiesHook;
useClients: ClientsHook;
useTimer: TimerHook;
}
const IOContext = React.createContext<IOContext>();
const useIOContext = () => useContext(IOContext);
export const useActivities: ActivitiesHook = (id, limit) => {
const io = useIOContext();
return io.useActivities(id, limit);
}
export const useClients: ClientsHook = () => {
const io = useIOContext();
return io.useClients();
}
export const useTimer: TimerHook = () => {
const io = useIOContext();
return io.useTimer();
}
Things picked up rapidly from here. I added more features by writing fast, integrative component tests using react-testing-library and my in-memory store. Each test would render the entire app with a pre-existing data set, interact with the application, and assert, either directly against the store, or through the UI, that the appropriate data have been changed. Here's one of these fast, integrative tests:
test("tags added to an activity will be added to the tag list", async () => {
const client = aClient();
const store = new MemoryStore([client]);
const tenant = aTenant();
const ui = render(<MemHarness store={store} tenant={tenant} />);
const name = client.name;
const card = within(await ui.findByLabelText(`${name} card`));
fireEvent.click(card.getByLabelText(`start timer`));
fireEvent.click(card.getByLabelText(`edit activity`));
const person = faker.name.findName();
addTag(ui, person);
fireEvent.click(ui.getByLabelText(`save`));
fireEvent.click(card.getByLabelText(`stop timer`));
await waitFor(
() => expect(store.activitiesFor(client.id)).toHaveLength(1)
);
expect(store.activitiesFor(client.id)).toContainEqual(
expect.objectContaining({ tags: [person] })
);
fireEvent.click(card.getByText(client.name));
fireEvent.click(ui.getByLabelText(`tags`));
expect(ui.queryByText(person)).toBeTruthy();
})
The MemHarness is a utility to render the app in an in-memory environment, including a fake authentication provider and the aforementioned memory store:
const MemHarness = ({ tenant, store }) =>
<FakeAuthProvider tenant={tenant}>
<MemoryIOProvider store={store}>
<MemoryRouter>
<App />
</MemoryRouter>
</MemoryIOProvider>
</FakeAuthProvider>;
At this point I was becoming more and more frustrated with Cypress; running tests requires the Cypress app to be running, my single E2E test was flaky for no apparent reason, and it was difficult to understand exactly why the test failed when it did. As a result, I found myself not running the E2E often, which defeats the whole point of having an E2E test. Adding these hurdles to the fact that I found myself disagreeing with the Cypress approach and philosophy, I made the decision to ditch Cypress. Having previously used Puppeteer, I decided to give Playwright a try, given that it's kind of a better version of Puppeteer. I quickly ported my E2E to use the Playwright API and extended it to populate all possible activity fields.
Once I achieved feature-parity against Clockify (for my very simple use case), I developed (again, via a fast, integrative test) the ability to import a Clockify-format CSV file. Once this was achieved, I deployed to production, imported my Clockify data and started using Chronomatic to track my client-related activities. For the fun of it, I also added Continuous Delivery using GitHub Actions, so that every push / merge to master is deployed to production in a bit over 5 minutes.
The total amount of time spent on this project so far has been around 80 hours. Ironically, I didn't keep good track of when I worked on it, because I always found myself going back to it whenever I had a few spare minutes. I’ve been writing software since 1995, when I was 14, and the last time I wrote software for myself, as opposed to writing software for an employer, was in 1998 when I wrote a Pascal program to track my CD collection - catalog, loans, etc. The biggest take-out from the Chronomatic experience is that I still really like writing software and can get totally obsessed about something I care about.
Which is good news.
Thanks for the interesting article. Our company is constantly improving the software to facilitate the work of the personnel department.