timeclock

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

README

Timeclock

Time tracking for payroll: clock in and out, time by customer and project, vacation and sick time per pay cycle, timesheets, approvals, exceptions, and reports a payroll system can import.

It is one Go module with its web app embedded. It runs two ways:

  • Inside a host application, mounted under a path. The host says who is calling and answers questions about its people; Timeclock keeps the time.
  • On its own, as timeclock-server, with its own sign-in.

What it does

  • The clock. One running clock per person: say what you are working on, pick its project, and start. Choosing another project while it runs moves the clock to it without stopping: the time so far stays where it was. Time can also be added and corrected by hand.
  • Projects. Every entry can be tagged with a project: a customer's, or internal work with no customer. An organization can require one.
  • The week. A calendar of the workweek, a day to a column: drag to add time, drag a block or its edge to change it. Entries may overlap, as a meeting inside a longer stretch does; worked hours count the overlap once, so they are never more than the time that passed.
  • Overview. Hours per day by project, each project's share of the week, and who is on the clock now.
  • Pay periods. Weekly, biweekly, twice a month or monthly, set by an admin.
  • Time zones. The organization has one, and a person can have their own: their times show in it, and their days and workweeks start at its midnight. Pay periods are the same calendar dates for everyone.
  • Overtime. Time beyond a weekly threshold is overtime, counted per workweek on the day it was worked, so a week that straddles two pay periods puts its overtime in the right one. People can be marked exempt.
  • Time off. Vacation and sick hours are recorded per day and reported per pay period. Balances stay in the payroll system.
  • Timesheets. Each period a person submits their timesheet, which locks that period's time. A manager or admin can send it back.
  • Approvals. Timesheets and time off can each need approval, from the person's manager or an admin, or not. Nobody approves their own.
  • Exceptions. What payroll should look at before paying: a clock left running, a very long entry, a timesheet not submitted or not approved, pending time off, overtime, a period with nothing recorded.
  • Reports. Hours per person per pay period, exported as a CSV for Gusto's hours import, and hours by customer, project and person.
  • Audit log. Every change to payroll data, with who made it.
  • Workspaces. A workspace is one organization's Timeclock: its people, time, settings and look. Every table carries its workspace and every query names it, so one deployment can hold many. A host with several organizations says which one a request is in; without that there is one.
  • Themes. A look is a few values: for light and for dark, an accent, a background and a contrast, and optionally the same for the sidebar; every other color follows from them. A workspace admin sets the workspace's in Settings, with the page wearing the draft as it changes; a host application can hand Timeclock its own; and each person chooses light, dark or the system's.
  • Reminders. With a host notifier, people are told once about a clock left running and a timesheet that is due.

Embedding

tc, err := timeclock.New(ctx, timeclock.Options{
	DatabaseURL: dsn,          // the host's own PostgreSQL database
	BasePath:    "/timeclock", // where the host mounts it
	Caller: func(r *http.Request) (string, bool) {
		return userIDFromSession(r) // the host's signed-in user
	},
	Directory:       directory,     // a host.Directory over the host's people
	Notifier:        notifier,      // optional: a host.Notifier for reminders
	HomeURL:         "/",           // optional: a link back to the host
	HomeLabel:       "Portal",
	SignInURL:       "/login?next=",
	SignOutURL:      "/api/auth/logout",
	ThemeStorageKey: "portal-theme", // optional: follow the host's light/dark choice
	Theme:           theme,          // optional: a host.Theme, so Timeclock matches the host's colors
	Workspace:       workspaceOf,    // optional: func(*http.Request) (key string, ok bool), for a host with several organizations
})
if err != nil {
	return err
}
defer tc.Close()
go tc.Run(ctx)                  // reminders, until ctx ends
mux.Handle("/timeclock/", tc)   // requests arrive with the base path still on them

examples/host is a complete host in one file: its own people, several organizations, and its own colors.

host.Directory is two methods: Person(ctx, id) and People(ctx). A host.Person has an id, a name, an email, whether they are an admin (run payroll), and optionally their manager's id; an admin can set managers in Timeclock too, which takes precedence.

Timeclock keeps its tables, and hopper's job tables, in its own schema (timeclock by default) and migrates it at startup under an advisory lock, so replicas can start together and it can share the host's database. Options.SkipMigrations leaves migrating to the host.

It is built on foundation: errors are RFC 9457 problems with stable codes (/problems/timeclock/<code>), the handler sets security headers and a strict CSP, and refuses cross-site writes.

The clock in the host's own pages

@giraffesyo/timeclock is the clock bar as an npm package, for the host's own header: a ready-made ClockBar, a headless useClock() hook, and a store with no React. Timeclock's own bar is that package.

import { ClockBar } from '@giraffesyo/timeclock/react';
import '@giraffesyo/timeclock/styles.css';

<ClockBar basePath="/timeclock" />;

Standalone

timeclock-server is Timeclock with its own accounts. Nobody is signed in until they accept an invitation and set a password.

TIMECLOCK_DATABASE_URL=postgres://... \
TIMECLOCK_PUBLIC_URL=https://time.example.com \
TIMECLOCK_ADMIN_EMAILS=payroll@example.com \
TIMECLOCK_SECRET_KEY=<32+ random characters, kept out of the database's backups> \
TIMECLOCK_SMTP_HOST=smtp.example.com TIMECLOCK_SMTP_FROM='Timeclock <time@example.com>' \
TIMECLOCK_SMTP_USERNAME=... TIMECLOCK_SMTP_PASSWORD=... \
timeclock-server
  • Getting in. Each address in TIMECLOCK_ADMIN_EMAILS is invited when the server first starts. After that, a workspace's admins invite people from Settings → People, and whoever runs the server can too: timeclock-server invite KEY EMAIL [--admin]. There is no open sign-up.
  • Workspaces. timeclock-server workspace KEY "Name" --admin EMAIL makes another organization on the same server and invites its first admin. Someone in more than one switches between them from the top of the sidebar.
  • Passwords. At least 10 characters, with no rules about what they contain; ones known from a data breach are refused (the check sends five characters of a hash, never the password; TIMECLOCK_BREACH_CHECK=off turns it off). They are stored as Argon2id hashes. Repeated wrong guesses pause sign-in for the account and for the address they come from; behind a reverse proxy, set TIMECLOCK_TRUST_PROXY=1 so that address is the client's (from X-Forwarded-For) and not the proxy's.
  • Sessions. A random token in an HttpOnly, SameSite=Lax cookie, kept only as a hash. Signing out, resetting or changing a password ends sessions at the server.
  • A second step. Anyone can add an authenticator app to their account (with recovery codes for when the phone is gone); sign-in then asks for its code after the password, and a reset link doesn't get around it. A passkey signs in on its own, with a fingerprint, face or device PIN. Both are set up under Account.
  • Email. Invitations and password resets go by SMTP. Without a mail server, they are written to the log, and an invitation's link is shown to the admin who made it.
  • Single sign-on. A workspace's admins can give it its own OpenID Connect provider in Settings → Sign-in, require it, and let anyone it signs in join; Settings shows a sign-in link that goes straight to the provider. What a workspace's provider says counts in that workspace only: a session that came in through it sees that workspace and can't change how the account signs in. TIMECLOCK_OIDC_ISSUER, _CLIENT_ID and _CLIENT_SECRET add a provider for the whole server instead. Either way the redirect URL is <public URL>/auth/callback.
  • The secret key. TIMECLOCK_SECRET_KEY seals authenticator secrets and providers' client secrets in the database (AES-256-GCM). Losing it means setting those up again; it isn't needed to read time or sign in with a password.

See cmd/timeclock-server/main.go for every variable. The Dockerfile builds the image.

Development

Needs Go 1.27, Node 26 with pnpm 11, and Docker.

make install
make dev        # http://localhost:8090, signed in as DEV_USER (an admin)
make test-db    # Go tests, with the database tests on
make check      # every linter and test
make vuln       # known vulnerabilities in the Go code
make e2e        # the end-to-end tests: the built server, driven by real browsers
make api        # after changing an API operation: regenerate the web app's types

In development the Go server proxies the web app from Vite, so open the Go server's address. Vite's own address (:5174) passes the API on to the Go server and works too, but only the Go server applies the CSP.

Where What
timeclock.go New, Options: the handler a host mounts
host/ Directory and Notifier: what a host implements
internal/clock/ The payroll rules and their storage. Every statement names its workspace as $W; one that doesn't is refused (workspace.go)
internal/api/ The HTTP API (huma); timeclock-server openapi prints its document
internal/standalone/ A standalone server's accounts: passwords, sessions, invitations, workspaces to switch between, email, and OIDC
migrations/ goose migrations, NNNNN_name.sql
web/ The Vite app, embedded in the binary
web/packages/timeclock/ @giraffesyo/timeclock, the clock for a host application; the app is built on its source
examples/host/ A small application with Timeclock mounted in it
web/e2e/ End-to-end tests (Playwright): each test is its own people, against a schema made for the run. They drive four servers: one signed in as whoever a test says, one with its own accounts, one set up like a real deployment (mail by SMTP, identity providers, a breach list, a proxy; the pretend ones are services.mjs), and examples/host

The payroll export

GET /api/v1/reports/payroll.csv?day=<any day in the period> has one row per person whose time is settled (their timesheet is approved, or submitted when timesheets need no approval), with the columns last_name, first_name, gusto_employee_id, regular_hours, overtime_hours, double_overtime_hours, pto_hours, sick_hours. ?all=true includes everyone. Check the columns against the template Gusto gives your company before the first import; the column names are in internal/api/reports.go.

Releasing

Commits on canary follow Conventional Commits, and release-please keeps a release PR open from them. Merging that PR:

  • publishes a timeclock-vX.Y.Z GitHub release with the notes;
  • tags the Go module vX.Y.Z on a commit that adds the built web/dist, so a tagged version carries the app while canary stays free of build output. A host depends on that tag, never on canary, which embeds no app;
  • publishes @giraffesyo/timeclock at the same version to npm, signed in through npm's trusted publishing.

make release VERSION=vX.Y.Z tags the module by hand, as a fallback.

License

Apache License 2.0.

Documentation

Overview

Package timeclock tracks time for payroll: clocking in and out, time by project and customer, vacation and sick time per pay cycle, timesheets, approvals, exceptions and reports a payroll system can import.

It runs inside a host application, which mounts Timeclock under a path, tells it who is calling and answers its questions about people through a host.Directory:

tc, err := timeclock.New(ctx, timeclock.Options{
	DatabaseURL: dsn,
	BasePath:    "/timeclock",
	Caller:      func(r *http.Request) (string, bool) { return userIDFromSession(r) },
	Directory:   directory,
})
go tc.Run(ctx)
mux.Handle("/timeclock/", tc)

cmd/timeclock-server runs it on its own, with its own sign-in.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func OpenAPI

func OpenAPI() *huma.OpenAPI

OpenAPI returns the API's OpenAPI document without connecting to anything. The web app's client types are generated from it.

Types

type Options

type Options struct {
	// DatabaseURL is the PostgreSQL database holding Timeclock's schema,
	// normally the host's own.
	DatabaseURL string
	// Schema is Timeclock's schema in that database. Defaults to "timeclock".
	Schema string
	// SkipMigrations leaves migrating to the host. By default New applies
	// Timeclock's migrations under an advisory lock, so replicas can start
	// together.
	SkipMigrations bool
	// BasePath is where the host mounts Timeclock, such as "/timeclock".
	// Requests reach Timeclock with it still on the path. Empty serves at
	// the root.
	BasePath string
	// Caller returns the host user making a request, from the host's
	// session or API key.
	Caller func(r *http.Request) (userID string, ok bool)
	// Directory answers questions about the host's people.
	Directory host.Directory
	// Notifier delivers reminders (a clock left running, a timesheet that is
	// due) through the host's notifications. Without one, none are sent.
	Notifier host.Notifier
	// HomeURL and HomeLabel link from Timeclock back to the host, such as
	// "/" and the host's name.
	HomeURL   string
	HomeLabel string
	// SignInURL is where the host signs a person in, ending with the query
	// parameter that takes the path to return to, such as "/login?next=".
	// Timeclock's web app sends a signed-out browser there.
	SignInURL string
	// SignOutURL is the host's same-origin endpoint that ends its session.
	// Timeclock's web app POSTs to it and then loads HomeURL. Without it,
	// Timeclock offers no sign-out.
	SignOutURL string
	// AccountsURL is where a standalone server manages its own accounts
	// (workspaces to switch between, invitations, passwords). A host that
	// has its own users leaves it empty.
	AccountsURL string
	// ThemeStorageKey is the localStorage key where the host keeps the
	// person's light, dark or system choice, so Timeclock matches it.
	// Without it, Timeclock follows the system.
	ThemeStorageKey string
	// Workspace names the workspace a request is in, for a host with more
	// than one organization: each key is its own people, time and settings,
	// made the first time it is seen. Without it, everyone is in one.
	Workspace func(r *http.Request) (key string, ok bool)
	// Theme is the host's look, so Timeclock mounted in it matches: for
	// light and for dark, an accent and a background. A workspace admin can
	// still set the workspace's own in Timeclock's settings.
	Theme host.Theme
	// Routes registers extra handlers beside Timeclock's own, such as a
	// standalone server's sign-in.
	Routes func(mux *http.ServeMux)
	// DevServer is the Vite dev server, such as "http://localhost:5174",
	// proxied while the module embeds no web build.
	DevServer string
	// HSTS sends Strict-Transport-Security. A host that sets its own
	// security headers leaves it off.
	HSTS bool
	// Logger defaults to slog.Default().
	Logger *slog.Logger
}

Options configures a Timeclock.

type Timeclock

type Timeclock struct {
	// contains filtered or unexported fields
}

Timeclock is an http.Handler for everything under its base path, and background work started by Run.

func New

func New(ctx context.Context, opts Options) (*Timeclock, error)

New connects to the database, applies migrations and builds the handler.

func (*Timeclock) Close

func (t *Timeclock) Close()

Close releases the database pool.

func (*Timeclock) Run

func (t *Timeclock) Run(ctx context.Context)

Run does Timeclock's background work until ctx ends: reminding people, through the host's Notifier, about a clock left running or a timesheet that is due. Without a Notifier it returns when ctx ends.

func (*Timeclock) ServeHTTP

func (t *Timeclock) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP serves Timeclock's API and web app.

Directories

Path Synopsis
cmd
timeclock-server command
Command timeclock-server runs Timeclock on its own, with its own accounts.
Command timeclock-server runs Timeclock on its own, with its own accounts.
examples
host command
Command host is the smallest application that has Timeclock inside it: it mounts Timeclock under /timeclock, says who is calling and which of its organizations they are in, and gives Timeclock its own colors.
Command host is the smallest application that has Timeclock inside it: it mounts Timeclock under /timeclock, says who is calling and which of its organizations they are in, and gives Timeclock its own colors.
Package host defines what a platform embedding Timeclock tells it about its people and, optionally, how to reach them.
Package host defines what a platform embedding Timeclock tells it about its people and, optionally, how to reach them.
internal
api
Package api is Timeclock's HTTP API, defined with huma.
Package api is Timeclock's HTTP API, defined with huma.
clock
Package clock is Timeclock's payroll logic and storage: pay periods, time entries, time off, timesheets, approvals, exceptions and reports.
Package clock is Timeclock's payroll logic and storage: pay periods, time entries, time off, timesheets, approvals, exceptions and reports.
standalone
Package standalone gives a Timeclock that runs on its own what a host would: accounts with a password, the workspaces each belongs to, sessions, invitations and password resets by email, optional sign-in with an OpenID Connect provider, and a directory of each workspace's people.
Package standalone gives a Timeclock that runs on its own what a host would: accounts with a password, the workspaces each belongs to, sessions, invitations and password resets by email, optional sign-in with an OpenID Connect provider, and a directory of each workspace's people.
Package migrations embeds Timeclock's SQL schema migrations, applied with goose.
Package migrations embeds Timeclock's SQL schema migrations, applied with goose.
Package web embeds the compiled Vite frontend.
Package web embeds the compiled Vite frontend.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL