Internal time tracking tool for the Woven Research & Insights team. Tracks time logged against client projects with workspace isolation, Microsoft SSO, and reporting.
- Projects — Auto-generated project codes (workspace-configurable prefix, e.g. WRI-001, WRI-002, …), project types with color tags, budgeted hours with usage progress bar, file attachments, and team member allocation (with a designated SPOC per project); assigned members are emailed when added to a project. The list view is kept compact (Code, Project Name, Type, Request Date, Client, Users Assigned, Status, TAT) and sorts by Request Date (newest first) by default; Requestor and Credits are omitted from the list and shown only in the detail panel, which surfaces every project field (Request Date, Requestor, Report Initiated, Report Delivered, TAT, Users Assigned, Credits, budget usage, members, timesheets, and documents). A "Users Assigned" column/field shows project members as soon as they're assigned, regardless of whether they've logged time yet, and is visible to all roles, not just admins. Admins can export the full project list to CSV.
- Timesheets — Log time against your assigned projects only; entries are tied to the logged-in user automatically and can only be viewed, edited, or deleted by their owner (or a workspace admin — only admins can see everyone's entries, via an "All Entries" toggle). The daily 24h logging cap is scoped to the active workspace, so hours logged in one workspace never block entries in another for users who belong to multiple workspaces. The Log Time project picker only lists active projects (an in-progress edit keeps its own project selectable even if that project has since been put on hold/completed/cancelled). Log Time defaults to the project's Report Initiated date and is constrained to fall within the project's Report Initiated / Report Delivered window (enforced both client- and server-side)
- Report status updates — Project members (not just admins) can update a project's status inline from the Projects table; all other project fields remain admin-only
- Clients & Contacts — Multi-project clients with contacts (name, email, phone required); contacts can be set as project requestors
- Reports — Time by user, project, client, or period (Daily / Weekly / Monthly, with business-day TAT per project), with budget vs. logged hours visualization, CSV export, and a report delivery calendar view showing delivered/upcoming report dates; "Users Assigned" reflects project membership, not just users who've logged time. The By Project view has an All / Active / Completed status filter
- Daily backup email — Every day at 8:00 AM IST, the server emails a workbook (Projects + Timesheet Entries sheets) to a fixed recipient; sent via Microsoft Graph
sendMail(trying each configuredSUPER_ADMIN_EMAILsender in turn) when Microsoft SSO is configured, otherwise via SMTP. A super admin can also trigger it on demand fromPOST /api/admin/send-backup-now - Workspaces — Each team gets an isolated workspace; users can belong to multiple workspaces; any authenticated user can create a new workspace (becoming its admin); new sign-ups can alternatively join an existing workspace that already has a member sharing their email domain, joining as a regular member. Each page has its own URL route
- Settings (Admin) — Workspace admins manage project types, workspace users/roles, and clients & contacts; a super admin (configured via
SUPER_ADMIN_EMAIL) can additionally manage all workspaces org-wide - Microsoft SSO — Azure AD OAuth2 login restricted to the configured tenant domain; dev-login fallback when credentials are not configured
| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite |
| Backend | Express 4 + Node.js built-in node:sqlite |
| Database | SQLite (via Node 22 native module) |
| Auth | Azure AD OAuth2 + custom SQLite sessions |
| File uploads | multer (stored in uploads/) |
| Containerization | Docker + Docker Compose (autoheal for health-based restarts); PM2 supported as a legacy alternative |
| Reverse proxy | Shared nginx-proxy container on the host (--network host, config outside this repo — routes several unrelated domains besides this app) |
Node 22+ required — the
node:sqlitebuilt-in is only available from Node 22 onwards.
- Node.js v22+
- npm
# Install dependencies
npm install
# Copy and fill in environment variables
cp .env.example .envEdit .env with your values (see Environment Variables).
# Start both backend and Vite dev server concurrently
npm run devBackend runs on http://localhost:3000, Vite dev server on http://localhost:5173 (proxies /api and /auth to the backend automatically).
When AZURE_CLIENT_ID is not set, a dev-login form is shown instead of Microsoft SSO — no Azure credentials needed for local development.
# Build the frontend
npm run build
# Start the server (serves built frontend + API)
npm startThe server serves the compiled dist/ and all /api/* routes from a single Express process.
The app ships with a multi-stage Dockerfile and a docker-compose.yml that runs the app plus an autoheal sidecar, so the service comes back on its own whether it crashes outright or just goes unresponsive:
restart: unless-stoppedrelaunches the container whenever the process exits.- The image's
HEALTHCHECKhits/api/health;autohealwatches container health and force-restarts anything Docker marks unhealthy (a hang that never exits on its own). - The SQLite DB and
uploads/live in named volumes (db-data,uploads-data) so they survive rebuilds/restarts.
# On the server, with .env present (see below)
docker compose up -d --build
docker compose logs -f app # tail logs
docker compose ps # check health statusThe container listens on 3000 internally and is published on host port 3009. Production traffic is routed by a separate, shared nginx-proxy container (--network host, config at /opt/alternative-ens-deployment/nginx.production.conf on the host — not part of this repo, and not Caddy despite earlier docs here saying so) whose time_tracking upstream points at 127.0.0.1:3009 (see nginx-proxy below). Before repointing ports: to a different host port, check that nginx config first — 3002 is already claimed by an unrelated site (salesforce_lens_web / lens.nativeworld.com) and 3007 was the legacy PM2 port; colliding with either has previously taken production down (see #103, and the "Disable deploy.yml auto-trigger" commit for a second incident where stopping what looked like a stale container on a shared port took the real production container down for ~2 hours).
Production deployment (current): the GitHub Actions workflow (.github/workflows/deploy.yml) is manual-trigger only (workflow_dispatch) — it is not wired to run on every push to main, despite what earlier revisions of this doc said. The push: branches: [main] trigger was disabled after the incident above and has been left disabled deliberately; re-enable it only once this deploy path has run stably for a while. To deploy, run the "Deploy to Production" workflow manually (Actions tab → Deploy to Production → Run workflow, on main). Once triggered, it rsyncs the repo to /var/www/time-tracking/ on the server, runs docker compose up -d --build --remove-orphans, and polls docker inspect's health status until the container reports healthy (failing the workflow and dumping the last 100 log lines if it doesn't within ~2 minutes).
Migrating an existing PM2 deployment to Docker (already done in production, kept here for reference): stop and remove the PM2 process first — pm2 delete woven-time-tracking — then run docker compose up -d --build from /var/www/time-tracking, and repoint the time_tracking upstream in the shared nginx-proxy config from PM2's port to this container's port (see nginx-proxy below). The SQLite DB isn't shared automatically; to carry over existing data, copy the old timetracking.db and uploads/ into the new named volumes before first start (e.g. docker run --rm -v woven-time-tracking_db-data:/data -v /var/www/time-tracking:/old alpine cp /old/timetracking.db /data/timetracking.db, similarly for uploads-data).
Only needed if Docker is unavailable on the host and you must fall back to the pre-Docker deployment path — production no longer runs this by default.
# Sync server files
rsync -avz --exclude node_modules --exclude dist --exclude '*.db' --exclude .env --exclude uploads \
./ user@your-server:/var/www/time-tracking/
# On the server
cd /var/www/time-tracking
npm install --omit=dev
npm run build
pm2 restart woven-time-trackingSee PM2 & nginx-proxy for full server setup.
Copy .env.example to .env and fill in:
| Variable | Description |
|---|---|
AZURE_TENANT_ID |
Azure AD tenant ID or domain (e.g. woventalent.in) — also used to reject SSO logins from email domains outside this tenant |
AZURE_CLIENT_ID |
Azure AD application (client) ID |
AZURE_CLIENT_SECRET |
Azure AD client secret value |
AUTH_REDIRECT_URI |
OAuth2 callback URL (e.g. https://your-production-domain.example.com/auth/callback) |
SUPER_ADMIN_EMAIL |
Email (or comma-separated emails) granted the super_admin global role, which can manage all workspaces |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM |
SMTP credentials for project-assignment emails (used as a fallback when Microsoft SSO isn't configured); assignment emails are silently skipped if unset |
APP_URL |
Public app URL, used as the link target in assignment emails (set this to your production domain) |
PORT |
Port for the Express server (default 3000) |
NODE_ENV |
Set to production to enable secure (HTTPS-only) session cookies |
DB_PATH |
Path to the SQLite database file (default <app dir>/timetracking.db) — set by docker-compose.yml to point at the db-data volume |
UPLOAD_DIR |
Directory for uploaded project files (default <app dir>/uploads) — set by docker-compose.yml to point at the uploads-data volume |
If AZURE_CLIENT_ID is not set, the app falls back to a dev-login form.
GET /api/health returns { status: 'ok' } and requires no auth — used by the Docker HEALTHCHECK and any external uptime monitor.
Production runs on Docker (see Docker (production) above); PM2 is kept only as a manual fallback if Docker is ever unavailable on the host. The PM2 config reads .env at startup and passes all variables to the Node process:
pm2 delete woven-time-tracking # needed after env changes
pm2 start ecosystem.config.cjs
pm2 saveThe app must be started with --experimental-sqlite:
node_args: '--experimental-sqlite'Production is not fronted by Caddy, despite what earlier revisions of this README said — that was an earlier session's incorrect assumption, later corrected after reading the real config on the host. Traffic for every domain on this host, including time.woventalent.in, is routed by a single shared nginx-proxy container running with --network host, configured at /opt/alternative-ens-deployment/nginx.production.conf (outside this repo, independently managed). The relevant upstream block:
upstream time_tracking {
server 127.0.0.1:3009; # this app's Docker container (see docker-compose.yml)
}
If falling back to the legacy PM2 path, that upstream line is the one to repoint — back to 127.0.0.1:3007 (PM2's port). Never point it at a port another upstream on this same shared proxy already uses (e.g. 3002 serves an unrelated site, salesforce_lens_web / lens.nativeworld.com) — and never assume a container holding a port is "stale" without checking what it actually serves first; doing so once took the real production container down for ~2 hours (see the "Disable deploy.yml auto-trigger" commit).
After editing the config, validate before reloading:
docker exec nginx-proxy nginx -t && docker exec nginx-proxy nginx -s reloadA full docker restart nginx-proxy (rather than a graceful reload) briefly interrupts every other site on the shared proxy, not just this one — prefer nginx -s reload unless a restart is specifically required (e.g. to reattach a detached bind mount).
- Go to Azure Portal → App registrations → New registration
- Set Redirect URI to
https://your-domain/auth/callback - Under Certificates & secrets, create a new client secret
- Copy the Tenant ID, Application (client) ID, and Secret value into
.env - Under API permissions, ensure
User.Read(Microsoft Graph) is granted
├── server.js # Express backend — all API routes, auth, SQLite schema
├── src/
│ ├── contexts/
│ │ └── AuthContext.jsx # Auth state (user, workspace, MSAuth)
│ ├── components/
│ │ ├── Sidebar.jsx
│ │ └── Modal.jsx
│ ├── pages/
│ │ ├── Login.jsx
│ │ ├── WorkspaceSelect.jsx
│ │ ├── Projects.jsx
│ │ ├── Timesheets.jsx
│ │ ├── Reports.jsx
│ │ ├── Calendar.jsx # Report delivery calendar view
│ │ └── Admin.jsx # Settings page (project types, users, clients, and — for super admins — all workspaces)
│ └── main.jsx
├── index.html
├── vite.config.js
├── Dockerfile # Multi-stage build (frontend build + production runtime)
├── docker-compose.yml # App + autoheal, persistent DB/uploads volumes — current production deployment
├── ecosystem.config.cjs # PM2 config (legacy fallback, not used in production)
├── .github/workflows/deploy.yml # Deploys to Docker on the production host — manual trigger only (workflow_dispatch), see Production Build & Deployment
├── .env.example
└── package.json
Internal tool — not for public distribution.