A personal time-tracking app for freelancers and independent consultants. Log hours to projects and customers, track day-rate earnings, book flat-fee services, and generate branded PDF timesheets — all in a single-page app hosted on Azure.
Try it first — open Settings (gear icon) and toggle Demo mode to explore the app with realistic fictional data, no account needed.
| Clock | Day detail | Projects |
|---|---|---|
![]() |
![]() |
![]() |
| Customers | Services | Settings |
|---|---|---|
![]() |
![]() |
![]() |
| Calendar | Drawer | Day detail |
|---|---|---|
![]() |
![]() |
![]() |
- Monthly calendar — colour-coded day cells with hour labels and coloured pips per project; click any day to open the detail panel
- Projects — link each project to a customer, set a day rate (or multiple rate periods), and see total logged hours + earnings per project
- Services — fixed-rate bookings (workshops, training, etc.) charged at a flat per-session rate
- Customers — group projects under customers with a colour identity; per-customer earnings and project summaries
- Earnings view — filter by customer, project, or service; choose month/quarter/year or a custom range; bar chart breakdown
- Timesheet export — generate a branded PDF timesheet and a zip of all attachments for a customer or project, entirely client-side (no round-trip to the server)
- Azure Blob Storage sync — full state stored as one JSON blob; optimistic concurrency via ETags so two browser tabs never silently overwrite each other
- File attachments — attach receipts or documents to any time entry; files are stored in Azure Blob Storage and downloaded via short-lived SAS URLs
- Demo mode — explore a full week of fictional demo data without touching your real data; switch back at any time from Settings
- Responsive mobile layout — full-width calendar with collapsible drawer navigation and a floating action button for quick entry
Browser (React 19 + TypeScript + Vite SPA)
│
├── /.auth/login/github GitHub sign-in via Azure SWA built-in auth
│
├── /api/state GET / PUT — full state as one JSON blob (ETag concurrency)
└── /api/attachments POST (SAS upload ticket) / GET (SAS download URL) / DELETE
│
Azure Functions (Node 22, managed, co-hosted with SWA)
│
Azure Blob Storage
├── container: state (state.json — the whole data model)
└── container: attachments (<entryId>/<attachmentId>)
Auth: Every route (/* and /api/*) is gated behind the owner role in staticwebapp.config.json. The GitHub identity you invite gets that role via az staticwebapp users invite. Unauthenticated visitors are redirected straight to the GitHub login page.
Punch is designed to be hosted by a single user. The entire stack runs on Azure services with a generous free tier.
- Azure CLI installed and signed in (
az login) - A GitHub account (used for authentication)
- This repository forked to your own GitHub account
# Create a resource group (pick any region)
az group create --name rg-punch --location westeurope
# Create a storage account (name must be globally unique, 3-24 lowercase chars)
az storage account create \
--name <your-storage-account> \
--resource-group rg-punch \
--sku Standard_LRS \
--kind StorageV2
# Create the two containers
az storage container create --name state --account-name <your-storage-account>
az storage container create --name attachments --account-name <your-storage-account>az staticwebapp create \
--name punch-app \
--resource-group rg-punch \
--location westeurope \
--sku Free \
--source https://github.com/<your-github-username>/punch \
--branch main \
--app-location / \
--api-location api \
--output-location dist \
--login-with-githubThe CLI will open a browser window for GitHub authorisation and add the AZURE_STATIC_WEB_APPS_API_TOKEN deployment secret to your fork automatically.
Retrieve your storage account key:
az storage account keys list \
--account-name <your-storage-account> \
--resource-group rg-punch \
--query "[0].value" -o tsvSet the required application settings on the Functions runtime:
az staticwebapp appsettings set \
--name punch-app \
--resource-group rg-punch \
--setting-names \
STORAGE_ACCOUNT_NAME=<your-storage-account> \
STORAGE_ACCOUNT_KEY=<key-from-above> \
STATE_CONTAINER=state \
ATTACHMENTS_CONTAINER=attachmentsLook up your GitHub identity in the SWA user list (sign in once first so your identity appears):
az staticwebapp users list --name punch-app --resource-group rg-punch -o tableThen assign the owner role to your identity:
az staticwebapp users update \
--name punch-app \
--resource-group rg-punch \
--user-details <your-github-username> \
--authentication-provider GitHub \
--roles ownerNote: The
ownerrole is a custom role defined instaticwebapp.config.json. It is separate from the Standard-plan password-protection feature — the Free tier supports it via theaz staticwebapp userscommands above.
The timesheet export downloads attachment files directly from Blob Storage in the browser. Add a CORS rule so the request isn't blocked:
az storage cors add \
--account-name <your-storage-account> \
--services b \
--methods GET HEAD \
--origins "https://<your-swa-hostname>.azurestaticapps.net" \
--allowed-headers "*" \
--exposed-headers "*" \
--max-age 3600Replace <your-swa-hostname> with the auto-generated hostname shown by:
az staticwebapp show --name punch-app --resource-group rg-punch --query defaultHostname -o tsvIf you add a custom domain later, run the command again for that origin.
Push to main — the GitHub Actions workflow (.github/workflows/deploy.yml) builds and deploys automatically. The app will be live at the URL from step 6 within a few minutes.
# Install frontend dependencies
npm install
# Install API dependencies
cd api && npm install && cd ..
# Copy API settings for local Azurite / emulator use
cp api/local.settings.json.example api/local.settings.json
# Edit local.settings.json if you want to point at real Azure storage instead of Azurite
# Start Azurite (Azure Storage emulator) in a separate terminal
npx azurite --silent --location .azurite
# Start the API (in a separate terminal)
cd api && npm run watch
# Start the frontend dev server
npm run devThe frontend runs at http://localhost:5173. Because there is no SWA auth proxy locally, set SKIP_AUTH_CHECK=1 in api/local.settings.json — the API will synthesise a dummy owner identity so all endpoints work without a real GitHub sign-in.
| Command | Description |
|---|---|
npm run dev |
Start Vite dev server |
npm run build |
Production build (tsc -b && vite build) |
npm run lint |
Run oxlint |
npx tsc -b |
Type-check frontend |
npx vitest run |
Run unit tests |
npm run test:e2e |
Run Playwright end-to-end tests |
cd api && npx tsc -p tsconfig.json |
Type-check the API |








