How to build a Harvest alternative
How to build a Harvest alternative with a running timer, a weekly timesheet, and a CSV. Follow the prompts, check each step, then skip invoicing.
You can learn how to build a Harvest alternative as a running timer, a weekly timesheet, and a CSV of hours. Harvest is that product. The catalog line is “Start timer. Stop timer. Remember what it was for.”
You pay $13–$17.50 a person a month. That is $156–$210 a year, and the card says the price scales with the team. The verdict is “Very buildable”. The first estimate is 4 hours: running timer, weekly timesheet, csv export.
There is no standalone teardown yet. The FreshBooks post already splits the timer from the invoice, so hours stay here and you paste the weekly total into a bill by hand. This guide is the walk. Prompts in order, then what to look at.
The audience is a small agency tracking billable time across clients and projects. If you need payroll, online payments, or a desktop tracker watching the team, keep paying.
Toggl Track is the other timer, $9–$18 a person a month, about 8 hours. That card is effort, not invoices. FreshBooks is $23–$60 a month, about 10 hours, when the week already exists and Ada needs a numbered PDF.
How to build a Harvest alternative without invoicing
The Harvest page splits the work into three rows.
The first row is the one you want. A running timer with a client, a project, a task, and a note. A weekly grid you can type into. A CSV of the saved hours. That is the 4-hour v1.
The second row is 12 hours. Clients with rates, project budgets, and week approvals. Do that after one real week has a total you trust.
The third row is 1 month and up. Invoicing, online payments, integrations, native apps. Skip it. The skip list says the same thing in one sentence: do not build payroll, invoicing, expenses, desktop tracking, or employee surveillance.
If you tell an agent to “clone Harvest”, it aims at the third row. Pin every prompt to the first one.
Here is the shape of the data. Nothing else has to exist at 4 hours.
const client = {
id: 'cl-ada',
name: 'Ada Rossi',
company: 'Bootcamp.dev',
}
const project = {
id: 'proj-writing',
clientId: 'cl-ada',
name: 'October writing',
}
const task = {
id: 'task-posts',
projectId: 'proj-writing',
name: 'Blog posts',
}
const timed = {
id: 'te-thu-0930',
ownerId: 'flavio',
projectId: 'proj-writing',
taskId: 'task-posts',
note: 'Draft the Harvest guide',
startedAt: '2026-10-29T08:30:00.000Z',
stoppedAt: '2026-10-29T10:00:00.000Z',
durationMinutes: 90,
timezone: 'Europe/Rome',
}
const typed = {
id: 'te-fri-typed',
ownerId: 'flavio',
projectId: 'proj-writing',
taskId: 'task-posts',
note: 'Edits after lunch',
startedAt: null,
stoppedAt: null,
localDate: '2026-10-30',
durationMinutes: 30,
timezone: 'Europe/Rome',
}
A client, a project, a task, and two kinds of time. One entry has real start and stop instants. The other is a number Ada typed into Friday. Both add up to the same cell.
time_entries allows at most one row per owner with stopped_at null. Timed rows cannot overlap. Duration is an integer number of minutes. 15 minutes is the smallest step, because the grid uses 0.25-hour cells.
Rates, budgets, and timesheet_approvals wait for the 12-hour row. The card lists those entities. They do not belong in tonight’s migrations.
Pick the stack you already write
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Harvest is Astro, HTMX, and D1.
This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The timer still has to live in one SQL row. A React dashboard does not replace the unique running-timer constraint.
Open Build your own Harvest. The guardrail is the useful sentence: store timestamps in UTC, prevent overlapping running timers, and log approval changes. Leave approvals for later. Keep UTC and the single runner in v1.
The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why invoicing waits. Pick one stack and stay on it.
Prompt 1. Scaffold Hourglass and stop
Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 4-hour row only. Running
timer, weekly timesheet, csv export.
Project name: Hourglass.
Public URL is https://hours.flaviocopes.com.
Owner is Flavio. Timezone Europe/Rome.
Seed client Ada Rossi at Bootcamp.dev.
Project: October writing.
Task: Blog posts.
Do not add billable rates, budgets,
or week approvals yet.
Do not add invoicing, online payments,
payroll, expenses, desktop tracking,
or employee surveillance.
When the plan is done, stop and wait.
Do not write implementation code
until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want pages like these:
/timerfor the runner, today’s entries, and the daily total/week/2026-10-26for the Monday-start grid/clientsand/projects/proj-writingfor names, not rates/exportfor the CSV download
You want tables clients, projects, tasks, time_entries. invoices should not appear. rates should not appear. timesheet_approvals can wait.
If the plan includes Stripe, a PDF renderer, or a screenshot agent, send it back. Paste the non-goal again.
When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.
What to check after the scaffold
Start the app. Sign in as Flavio. You should see an empty timer and an add-entry action.
Open the migrations. time_entries stores started_at and stopped_at as UTC instants. There is a partial unique index so only one row per owner can be running. Duration is an integer minute column, not a float.
If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to name the timezone, the overlap rule, and the CSV escape.
Prompt 2. Start one timer and stop it
The first journey on the card is this screen. Pick Ada, October writing, and Blog posts. Hit start. Add a note. Hit stop. The row lands on this week’s grid.
The guardrail is one runner. Starting a second timer has to stop the first at the same instant, even if two requests arrive together.
Build the timer for Hourglass.
Flavio picks client, project, task,
and an optional note, then starts.
started_at is now, stored in UTC.
timezone on the row is Europe/Rome.
At most one running timer per owner.
Starting another entry stops the
current one in the same transaction,
at the same instant, then inserts
the new runner.
Stop writes stopped_at and
durationMinutes as
round((stopped_at - started_at)
/ 60000) to the nearest 15 minutes,
minimum 15 if the span is shorter
and the owner confirms.
Reject a stop that would overlap
another timed entry. Two concurrent
starts leave exactly one runner.
Start and stop are the only
timer actions.
Seed te-thu-0930 as a stopped
example: 2026-10-29T08:30:00.000Z
to 2026-10-29T10:00:00.000Z.
Do not snapshot a billable rate.
Do not open an invoice.
Done when I can start on Blog
posts, start on a second task
through two near-simultaneous
requests, and the database has
one running row and no overlap.
async function startTimer(db, ownerId, draft) {
return db.transaction(async (tx) => {
const running = await tx.getRunning(ownerId)
if (running) {
await tx.stopEntry(running.id, draft.startedAt)
}
return tx.insertEntry({
...draft,
ownerId,
stoppedAt: null,
})
})
}
The insert and the stop share one transaction. The unique running index is the lock. The loser of a race still sees one runner after refresh.
function overlaps(a, b) {
const aEnd = a.stoppedAt ?? '9999-12-31T23:59:59.000Z'
const bEnd = b.stoppedAt ?? '9999-12-31T23:59:59.000Z'
return a.startedAt < bEnd && b.startedAt < aEnd
}
overlaps(
{
startedAt: '2026-10-29T08:30:00.000Z',
stoppedAt: '2026-10-29T10:00:00.000Z',
},
{
startedAt: '2026-10-29T09:00:00.000Z',
stoppedAt: '2026-10-29T09:30:00.000Z',
},
)
//true
A running row uses the far-future end, so it overlaps anything that starts before it stops. Typed rows have no instants. They cannot overlap.
What to check after the timer runs
Start on Blog posts at 09:30 Rome. The header shows a running duration.
Start on a second task without stopping. The first row now ends at that instant. One new runner.
Refresh. Still one runner. /week/2026-10-26 already shows the stopped 90 minutes under Thursday.
Hit start twice from two tabs in the same second. One running row. No overlap error in the log, because the second start was a switch.
If started_at looks like 2026-10-29 09:30 with no Z and no offset, the agent stored local time. Send prompt 2 back. Rome leaves CEST on 25 October 2026. After that, 09:30 Rome is 08:30Z. Get this wrong and Thursday’s cell moves.
Prompt 3. Fill the weekly timesheet
The fourth mini-prompt on the card is “The grid where typed hours and real timers coexist”. That is the rest of the 4-hour row.
Rows are project-and-task pairs. Columns are the seven local days, Monday to Sunday. A cell is a sum.
Build the weekly timesheet grid
for Hourglass.
URL is /week/2026-10-26, the Monday
in Europe/Rome. Rows are project
plus task. Columns are seven local
dates. A cell shows hours to two
decimals.
A cell sums two kinds of row:
timed entries split onto the local
dates they cover, and duration-only
entries someone typed as a number.
Editing a cell that holds one
typed row updates that row.
Editing a cell with several rows,
or any timed row, opens a
breakdown. Never overwrite a timed
entry by typing 3.5 into the cell.
Overlap checks run only on timed
rows. Typed rows cannot overlap.
Granularity is 0.25 hours.
Reject negatives and anything
above 16 hours in one cell.
A timed entry that crosses local
midnight keeps one durationMinutes
and contributes minutes to each
local date.
Daily and weekly totals equal the
sum of the cells. The running
timer is a projected figure on
today, not a saved cell.
Do not add submit or approve.
Do not import calendar events.
Done when Thursday shows 1.5 from
te-thu-0930, I type 0.5 on Friday
for Blog posts, the week total is
2, and typing over Thursday opens
the breakdown instead of deleting
the timed row.
function cellHours(parts) {
return parts.reduce((sum, part) => sum + part.minutes, 0) / 60
}
cellHours([
{ minutes: 90, kind: 'timed' },
{ minutes: 30, kind: 'typed' },
])
//2
Thursday is only the timed 90 minutes, so the cell reads 1.5. Friday is the typed 30 minutes, so it reads 0.5. The week is 2.
A timer that starts at 22:30 Rome and stops at 00:30 Rome is still 120 minutes on the entry. The grid puts 90 minutes on Thursday and 30 on Friday. Reports later use durationMinutes, not the split.
What to check after the grid fills
Open /week/2026-10-26. Thursday, October writing, Blog posts, 1.5.
Type 0.5 on Friday for the same row. Refresh. Friday stays 0.5. Week total 2.0.
Type 3.5 on Thursday. The page opens the breakdown with te-thu-0930. The 90 minutes are still there.
Add a second typed 15 minutes on Friday. The cell reads 0.75. Editing Friday now opens the breakdown, because two rows sit in that cell.
Cross midnight with a test timer. One entry. Two cells. durationMinutes is 120.
If the agent stored 1.5 as a float hour on the row, stop. Keep minutes in the database and divide by 60 when you show hours.
Prompt 4. Export the week as CSV
The first estimate ends on a file Ada can open in a spreadsheet. The Toggl card has the same escape rule, and Harvest needs it too. A note that starts with = is a string, not a formula.
Build the CSV export for Hourglass.
/export?from=2026-10-26&to=2026-11-01
downloads one file. Columns:
date, client, project, task, note,
hours, entry id.
Read only saved entries. A still
running timer is omitted. Two
exports with a runner going must
be byte-identical.
Hours come from durationMinutes
/ 60, two decimals. Dates are
the localDate in Europe/Rome for
typed rows, and the local start
date for timed rows that do not
cross midnight. A midnight-crossing
timed row emits one CSV line with
the full duration and the start
date, plus a note that it spans
two local days.
Escape every field. Values that
start with =, +, -, or @ get a
leading apostrophe so a spreadsheet
does not run them.
Sort by local date, then entry id.
The same filter, twice, diffs empty.
Do not add a billable amount
column.
Do not attach a PDF invoice.
Done when the week file has
te-thu-0930 at 1.50 hours, the
Friday typed row at 0.50, no
running row, and a note =CMD()
opens inert.
function csvCell(value) {
const text = String(value)
const safe = /^[=+\-@]/.test(text) ? `'${text}` : text
return `"${safe.replaceAll('"', '""')}"`
}
csvCell('Draft the Harvest guide')
//"Draft the Harvest guide"
csvCell('=CMD()')
//"'=CMD()"
The file is a snapshot of saved minutes. Change a rate later, this export still says 1.50. There is no rate column yet, because rates are the 12-hour row.
What to check after the CSV downloads
Export the week. Two data rows. Thursday 1.50. Friday 0.50.
Start a timer. Export again. Same bytes.
Edit the Friday note to =CMD(). Re-export. Open the file in a spreadsheet. The cell shows =CMD(), it does not execute.
Export twice. The files match.
If the agent added amount or invoice_id, delete those columns. Money waits for the 12-hour row. A PDF waits for FreshBooks.
Failures the agent ships anyway
Two running timers
A unique index on owner_id where stopped_at is null is the product. Application code that “checks first” loses the race. If two runners exist after a double click, the migration is wrong.
Local timestamps in the database
2026-10-29 09:30 without a zone will shift when you deploy, or when Rome leaves daylight saving. Store 2026-10-29T08:30:00.000Z. Display 09:30 to Flavio.
A float hour column
1.5 + 0.1 is how totals drift. durationMinutes is an integer. The grid divides by 60 for display.
An invoice PDF on the 4-hour row
Harvest the company sells invoicing. The 4-hour row on this site does not. The third row is 1 month, and the non-goal names invoicing on purpose. If invoices appears in the plan, you left the first estimate.
The FreshBooks teardown is that other app: clients, a number assigned at send, a public page, a PDF. Paste Thursday’s 1.5 into a line item by hand until you have a week you trust.
A desktop agent that watches the team
Screenshots, keystrokes, and app titles are surveillance. Harvest’s skip list names that. Toggl Track says the same in its guardrail. RescueTime is $7–$12 a month, about 3 days, when you want your own local app durations. Do not bolt that onto Hourglass.
Deploy the timesheet
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Hourglass wants Astro, HTMX, and D1. Put it on a subdomain you already own, hours.flaviocopes.com. D1 holds the tables. There is no cron on the 4-hour row. Start and stop are ordinary form posts.
The hosting post is that path: the adapter, then the bindings, then the custom domain.
Attach the domain on the Worker. Sign in as Flavio from your phone on cellular. Start a timer. Stop it. Confirm /week on the laptop shows the same minutes after a refresh.
Ship with Ada, October writing, and one real week of your own hours. Export the CSV. Open it. If Thursday is the number you remember, the v1 works.
Take a D1 backup before you delete the paid account. The week grid is the history you will miss, so treat that backup like a password.
Use it on real client work for a week, not as a demo. If you miss rates or a budget bar, those are the 12-hour row and the other mini-prompts on the card. Add them after the CSV has been wrong once, or right every day.
Then cancel Harvest, or Toggl, whichever one you pay for the timer.
What to skip on purpose
Leave out invoicing, online payments, payroll, expenses, desktop tracking, and anything that watches other people’s screens.
Rates that do not rewrite last month are the first mini-prompt on the card. Week locks are the second. A budget bar that counts hours or money is the third. I would not build those until the CSV matches the grid.
A receipt you need reimbursed is a different job. That is Expensify, $5–$9 a person a month, about 12 hours.
Money you assign to a category after the invoice clears is YNAB, about 12 hours. I wrote that walkthrough as Build a YNAB alternative. Hours are not assignable until Ada pays.
A public board that says whether the product is up is the status page guide. Components and incidents, not Thursday’s 1.5.
A cron that pings a URL is the uptime monitor guide. This app has no scheduled job at 4 hours.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Harvest. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only: running timer, weekly timesheet, csv export. Four hours.
Point it at one real project. Stop the timer. If Thursday’s cell is the number you remember, you can cancel.