timeclock

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: Apache-2.0 Imports: 18 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, started and stopped from any page. Time can also be added and corrected by hand.
  • Projects. Every entry can be tagged with a project, which belongs to a customer. An organization can require one.
  • Pay periods. Weekly, biweekly, twice a month or monthly, set by an admin. Days, workweeks and periods are cut in the organization's time zone.
  • 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.
  • 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
})
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

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.

Standalone

TIMECLOCK_DATABASE_URL=postgres://... \
TIMECLOCK_PUBLIC_URL=https://time.example.com \
TIMECLOCK_OIDC_ISSUER=https://accounts.google.com \
TIMECLOCK_OIDC_CLIENT_ID=... TIMECLOCK_OIDC_CLIENT_SECRET=... \
TIMECLOCK_SESSION_SECRET=<32+ random characters> \
TIMECLOCK_ALLOWED_DOMAIN=example.com \
TIMECLOCK_ADMIN_EMAILS=payroll@example.com \
timeclock-server

The OIDC provider's redirect URL is <public URL>/auth/callback. 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 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, not Vite's.

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
internal/api/ The HTTP API (huma); timeclock-server openapi prints its document
internal/standalone/ Accounts, OIDC sign-in and sessions for running alone
migrations/ goose migrations, NNNNN_name.sql
web/ The Vite app, embedded in the binary

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

make release VERSION=v0.2.0 builds the web app, commits web/dist on top of the current commit, and tags that commit, so a tagged version carries the app while main stays free of build output. Push the tag with git push origin v0.2.0. A host depends on a tag, never on main, which embeds no app.

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
	// 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
	// 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 sign-in.
Command timeclock-server runs Timeclock on its own, with its own sign-in.
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, sign-in with an OpenID Connect provider, a session cookie, and a directory of the people who signed in.
Package standalone gives a Timeclock that runs on its own what a host would: accounts, sign-in with an OpenID Connect provider, a session cookie, and a directory of the people who signed in.
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