A overhaul of the original BetterShift by panteLx to better suit our employee based needs.
  • TypeScript 99.4%
  • CSS 0.2%
  • Dockerfile 0.2%
  • JavaScript 0.1%
  • Go Template 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
gagasol 27be694239
All checks were successful
Create Release / Run Tests (push) Successful in 1m39s
Docker Dev Build / build-dev (push) Successful in 2m17s
Create Release / Create Release (push) Successful in 1m56s
2.3.0
2026-10-05 15:33:41 +02:00
.forgejo/workflows docs: fold the CI setup into the Kubernetes guide 2026-09-08 21:14:11 +02:00
.github fix: restore package-lock.json and repair the broken build 2026-09-08 18:57:04 +02:00
app feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
charts/shiftplaner chore: Updated the version in the chart.yaml to 2.3.0 2026-10-05 15:33:04 +02:00
components feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
demo Test 2026-08-21 00:29:02 +02:00
docker feat: add a hardened container image and Kubernetes deployment 2026-09-08 19:00:04 +02:00
docs docs: fold the CI setup into the Kubernetes guide 2026-09-08 21:14:11 +02:00
drizzle feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
hooks feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
k8s refactor: rename the chart and manifests to shiftplaner 2026-09-08 20:03:10 +02:00
lib feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
messages feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
public feat: Block requests when system is unhealthy (#108) 2026-01-04 19:11:31 +01:00
scripts chore: Bump versions for Next.js, React, and related dependencies (#140) 2026-04-04 02:13:21 +02:00
.dockerignore feat: add a hardened container image and Kubernetes deployment 2026-09-08 19:00:04 +02:00
.env.example docs: document every environment variable in .env.example 2026-09-08 20:32:49 +02:00
.gitignore feat!: auth system (v2) (#98) 2026-01-03 03:03:16 +01:00
CLAUDE.md feat: store avatar uploads in the data directory 2026-09-08 18:58:22 +02:00
components.json feat: initialize project with Next.js, TypeScript, and Tailwind CSS 2025-11-21 04:32:07 +01:00
docker-compose.yml feat: add a hardened container image and Kubernetes deployment 2026-09-08 19:00:04 +02:00
Dockerfile feat: Adds build metadata, build-info API and admin panel build infos (#110) 2026-01-04 22:18:13 +01:00
Dockerfile.k8s docs: fold the CI setup into the Kubernetes guide 2026-09-08 21:14:11 +02:00
drizzle.config.ts feat: initialize project with Next.js, TypeScript, and Tailwind CSS 2025-11-21 04:32:07 +01:00
eslint.config.mjs feat: add a hardened container image and Kubernetes deployment 2026-09-08 19:00:04 +02:00
instrumentation.ts feat: Block requests when system is unhealthy (#108) 2026-01-04 19:11:31 +01:00
LICENSE chore: add MIT License to the project 2025-11-22 04:30:39 +01:00
metadata.json Test 2026-08-21 00:29:02 +02:00
next.config.ts fix: restore the frame protection headers 2026-09-08 18:57:29 +02:00
package-lock.json 2.3.0 2026-10-05 15:33:41 +02:00
package.json 2.3.0 2026-10-05 15:33:41 +02:00
postcss.config.mjs feat: initialize project with Next.js, TypeScript, and Tailwind CSS 2025-11-21 04:32:07 +01:00
proxy.ts fix: restore the frame protection headers 2026-09-08 18:57:29 +02:00
README.md feat(scheduling): availability-based planning with a shift matrix 2026-10-05 15:30:53 +02:00
tsconfig.json feat!: auth system (v2) (#98) 2026-01-03 03:03:16 +01:00

logo

BetterShift — Shiftplaner Fork

Shift management for teams that plan by employee, not by shift title.

This is a fork of BetterShift by panteLx. It keeps everything the original does — unlimited calendars, external calendar sync, sharing, admin panel, ICS/PDF export, six languages — and adds employee-based rostering: shifts assigned to people, work locations, absence tracking, recurring fixed-shift rules and an automatic month planner. Plus a hardened container image built for Kubernetes.

License Self-hosted Fork

What this fork adds · Quick Start · Documentation · Upstream project by panteLx

About this fork

Upstream github.com/panteLx/BetterShift
Forked at feat: Add Czech translation (cs) (#144)
Development git.casa-due-pur.de/gabriel.gambel/Shiftplaner
Upstream demo bettershift.pantelx.com (shows the original interface)

This repository is not maintained by panteLx. It is an independent fork, run by gabriel.gambel, and changes are not pushed back upstream. Do not take issues, questions or support requests about this fork to the upstream project or its Discord — the people there did not write these changes and cannot support them.

Every feature the original documents still works here; the sections below describe what was added on top and what behaves differently. Full credit for the base application goes to the upstream authors — see Credits.

Screenshots (from the upstream project — the employee interface looks different) calendar_page admin_panel

What this fork adds

Employee-based interface — active by default

The original assigns each shift a free-text title and a preset. This fork adds a mode in which a shift belongs to a person from the calendar roster (shifts.userId), and that mode is the one this application runs in: ENABLE_EMPLOYEE_BASED_INTERFACE in lib/feature-flags.ts defaults to true and is left at its default here. Everything described in this README assumes it.

The roster comes from the calendar's owner plus everyone it is shared with, so the existing permission model decides who can be scheduled.

The original interface has not been removed — setting the variable flips every branch back:

NEXT_PUBLIC_ENABLE_EMPLOYEE_BASED_INTERFACE=false

The value is read into the client bundle at build time, so a container has to be rebuilt to switch modes in the browser.

What the flag actually switches:

employee-based (default, active) shift-based (original)
Clicking a day opens the employee shift sheet applies the selected preset, or opens the classic shift sheet
The floating + button opens the shift matrix (a whole month at once) opens the single-shift sheet
Shift editor EmployeeShiftSheet — person, location, time ShiftSheet — title, preset, all-day
Below the calendar — the classic shift list
Header extra Shifts button (own shifts; everyone's for admins) no such button
Employee settings a dialog inside the shift matrix, open to both admin roles not reachable
Availability reported per day, imported from e-mail in the calendar settings not used
Auto shift planning usable — it is driven by employee settings no input data, so nothing to fill

What the flag does not switch: work locations and absence management are available in both modes, and calendars, sharing, external sync, export, the admin panel and statistics behave exactly as upstream in either case.

Work locations

A calendar can have several locations (calendar_locations), each with its own name, colour, sort order and default start/end time. A shift references one location. Calendars without a location get a default one created automatically, so single-location teams never see the feature.

Default times cascade: fixed-shift rule → location → calendar → 09:00–17:00.

Absence management

Absences (absences) are tracked per calendar and, where possible, per user:

  • Types: absence and vacation, with an optional reason.
  • Single or recurring: a date range, or weekdays that repeat (e.g. every Monday and Thursday).
  • All-day or timed, so a partial-day absence only blocks the hours it covers.
  • Status: approved, pending or rejected — approved is the default.
  • Conflict detection: reporting an absence that overlaps an existing one is refused, the shift form flags a date on which the chosen employee is absent, and the month planner skips those days.

Reachable from a Report absence button in the header, a per-calendar absence sheet, a personal overview under /profile/absences, and an admin view per calendar.

Automatic shift planning

POST /api/calendars/[id]/fill-shift-plan fills a whole month from each employee's fixed-shift rules. Supported recurrence patterns:

Pattern Example
weekly every Tuesday
biweekly every other Tuesday (even or odd ISO week)
monthly_nth_day 1st/2nd/3rd/4th or last Friday of the month
monthly_day_of_month always on the 15th

For every matching day the planner skips employees with an approved absence and skips days that already hold an overlapping shift, then reports how many shifts it created and how many it skipped for each reason. Requires calendar-manage permission or superadmin.

Employee settings

Opened from the shift matrix (button Mitarbeitende) by either admin role. Configures per employee and calendar (employee_calendar_settings):

  • the planning mode — whether the person counts as available by default or may only be scheduled on days they reported,
  • preferred working days,
  • minimum, preferred and maximum hours per month,
  • whether the person may work a shift alone,
  • their fixed-shift rules (the input for the planner above).

Availability and the shift matrix

Two planning styles coexist, chosen per employee via the planning mode:

  • default-available — counts as available unless an approved absence says otherwise. This is the shops' model and stays the default.
  • availability-required — only schedulable on days explicitly reported. For warehouse staff who mail in the days they can work.

Availability lives in availabilities, one row per employee and day (UNIQUE(calendar_id, user_id, date), so re-importing a corrected mail is an upsert rather than a duplicate). Calendar settings → Verfügbarkeit hinzufügen pastes a German availability mail, parses dates and time windows (lib/availability-parser.ts) and shows an editable preview before saving.

The shift matrix (floating + button) fills a month in one pass: employees as rows, days × locations as columns, with hour budgets on the left and staffing per day at the bottom. Required head count comes from staffing_requirements; part-time counts proportionally, so half the default hours counts as 0.5. Applying writes creates, updates and deletes in one request, asks for confirmation first, and never touches externally synced shifts.

Shift overview dialog

A Shifts button in the header opens a dialog listing the signed-in user's own shifts — or, for admins and superadmins, everyone's, filterable by employee.

Container image for Kubernetes

Dockerfile.k8s builds a hardened image alongside the original Dockerfile. See Kubernetes deployment for the full story.

original Dockerfile Dockerfile.k8s
Base node:20-alpine (Node 20 is EOL) node:22-bookworm-slim, pinned by digest
User root 10001:10001, no shell, no home
Start sh -c "npm run db:migrate && node server.js" node docker/entrypoint.js
Runtime deps full production node_modules incl. npm, drizzle-kit only what Next traced into .next/standalone
Size ~1.1 GB ~305 MB
Root filesystem writable works with readOnlyRootFilesystem
Listening address IPv4 only, and binds to the pod name under Kubernetes dual-stack :: where available, never the pod name

Two problems this solves that are easy to miss:

  • The pod-name bind. Next's standalone server binds to process.env.HOSTNAME || "0.0.0.0", and Kubernetes sets HOSTNAME to the pod name in every container — probes on localhost then fail. docker/entrypoint.js overrides it before the server loads.
  • Writable state. Avatar uploads used to be written into public/ inside the image, so they vanished on restart. They now live in the data directory next to the database (UPLOADS_DIR) and are served by a route handler; the URL /uploads/avatars/<file> is unchanged.

Ready-to-use deployment artefacts ship with the repository:

  • k8s/ — Deployment, Service, PVC, ConfigMap, Secret template, Ingress, Kustomization
  • charts/shiftplaner/ — Helm chart

Both are pinned to a single replica with a Recreate strategy. That is not a tuning knob: SQLite lives on a ReadWriteOnce volume, and the rate limiter, health cache and external-sync loop are per process.

IPv6

The server binds :: where the platform allows it, serving IPv6 and IPv4 from one socket, and falls back to 0.0.0.0 otherwise. The Service manifests request PreferDualStack. Rate limiting was adapted with it: IPv6 clients get a whole /64, so limits are keyed on the prefix rather than the single address — otherwise rotating addresses would defeat every login and registration limit.

Features

Everything from the original, plus this fork's additions (bold):

Feature Description
Employee rostering Shifts assigned to roster members instead of free-text titles (active by default)
Locations Multiple work locations per calendar, each with colour and default times
Absences Single or recurring, all-day or timed, with conflict detection
Auto shift plan Fill a month from recurring fixed-shift rules, skipping absences
Employee settings Preferred days, hour budgets, lone-working flag, fixed shifts
Calendars Unlimited calendars with custom colors and names
Presets Reusable shift templates with labels, times, and colors (shift-based mode)
External Sync Subscribe to Google Calendar, Outlook, or iCal feeds (read-only)
Sharing Share via user accounts, guest access, or shareable links
Authentication Email/password, OAuth (Google, GitHub, Discord), or custom OIDC
Admin Panel User management, calendar administration, audit logging
Statistics Real-time shift tracking with visual charts
Export Download calendars as ICS or PDF
Localization English, French, German, Italian, Spanish, Czech
PWA Installable on mobile and desktop

Quick Start

Docker

docker run -d \
  -p 3000:3000 \
  -v shiftplaner-data:/app/data \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_URL=http://localhost:3000 \
  --name shiftplaner \
  git.casa-due-pur.de/gabriel.gambel/shiftplaner:latest

Open http://localhost:3000. The first registered user becomes superadmin.

Keep the generated secret — a fresh one on every start invalidates all sessions. The registry is private, so docker login git.casa-due-pur.de first.

Docker Compose

git clone https://git.casa-due-pur.de/gabriel.gambel/Shiftplaner.git
cd Shiftplaner
cp .env.example .env
# Edit .env and set BETTER_AUTH_SECRET
docker compose up -d

Kubernetes

The full walkthrough, from an empty namespace to a running pod.

1. Build and push the image. The registry path must be lowercase.

docker login git.casa-due-pur.de
docker build -f Dockerfile.k8s --build-arg VERSION=v2.2.1 -t git.casa-due-pur.de/gabriel.gambel/shiftplaner:2.2.1 .
docker push git.casa-due-pur.de/gabriel.gambel/shiftplaner:2.2.1

CI does this automatically on a tag. Images are built for linux/amd64; docs/KUBERNETES.md explains how to add arm64 and what else has to change for it.

2. Create the namespace.

kubectl create namespace shiftplaner

3. Pull credentials. The registry is private, and the Deployment references a secret named forgejo-registry:

kubectl -n shiftplaner create secret docker-registry forgejo-registry --docker-server=git.casa-due-pur.de --docker-username=<user> --docker-password=<forgejo-token>

4. Signing secret. Generate it once and keep it — replacing it later logs everyone out:

kubectl -n shiftplaner create secret generic shiftplaner-secrets --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)"

5. Adjust the configuration. In k8s/configmap.yaml set BETTER_AUTH_URL to the URL users will actually reach the app under, and in k8s/kustomization.yaml set the image tag you pushed. A BETTER_AUTH_URL that does not match the real address makes every login fail as a cross-origin request.

6. Deploy.

kubectl -n shiftplaner apply -k k8s/

7. Verify. The pod becomes ready once /api/health reports a working database; migrations run automatically during startup.

kubectl -n shiftplaner rollout status deployment/shiftplaner
kubectl -n shiftplaner port-forward svc/shiftplaner 8080:80

Open http://localhost:8080 — the first user who registers becomes superadmin.

8. Expose it. Add ingress.yaml to the resources list in kustomization.yaml after setting your host and TLS secret, and make sure the host matches BETTER_AUTH_URL.

With Helm instead

The chart is published alongside every release, so it does not have to be cloned:

helm repo add shiftplaner https://git.casa-due-pur.de/api/packages/gabriel.gambel/helm
helm install shiftplaner shiftplaner/shiftplaner --namespace shiftplaner --create-namespace --set config.betterAuthUrl=https://shifts.example.com

From a checkout it is charts/shiftplaner instead of shiftplaner/shiftplaner.

config.betterAuthUrl is required — the install fails with a clear message if it is missing, which is deliberate: a wrong value makes every login fail as a cross-origin request, and that failure is hard to read.

The chart generates the signing secret on first install and preserves it across upgrades; pass --set auth.existingSecret=<name> to manage it yourself. Every key in that secret is injected as an environment variable, so it is also where OAuth/OIDC credentials and REPO_TOKEN belong.

The update check is off by default in the chart: without config.repoOwner, config.repoName and config.repoApiUrl the app makes no outbound request and shows an empty changelog.

What to expect in operation

  • One replica, Recreate strategy, and no way around it. SQLite sits on a ReadWriteOnce volume, and the rate limiter, health cache and external-sync loop are per process. Every rollout therefore has a few seconds of downtime.
  • One volume holds everything mutable: /app/data carries the database and the avatar uploads. /tmp and /app/.next/cache are emptyDirs, needed only because the root filesystem is read-only.
  • Probes: startup and readiness use /api/health, liveness is a plain TCP check — pointing liveness at the health endpoint would turn a database outage into a restart loop.
  • Backup is a copy of that one volume:
kubectl -n shiftplaner exec deploy/shiftplaner -- tar cf - /app/data > shiftplaner-backup.tar
  • Upgrades: push a new tag, update the image, roll out. Migrations apply themselves at startup and there is no automatic rollback for schema changes, so take the backup first.

docs/KUBERNETES.md has the details, including dual-stack IPv6 and how the image differs from the original one.

From Source

git clone https://git.casa-due-pur.de/gabriel.gambel/Shiftplaner.git
cd Shiftplaner
npm install
cp .env.example .env
npm run dev

Migrations run automatically on first database access, so npm run db:migrate is only needed if you want to apply them ahead of time.

Documentation

Document Description
Kubernetes Hardened image, publishing, manifests, Helm chart, IPv6, backups
Authentication Setup Email/password, OAuth, OIDC configuration
Admin Panel User management, calendar administration
Permissions Sharing, access tokens, guest access
Migration Guide Enable auth on existing instances
CLAUDE.md Architecture notes and conventions for contributors

Configuration

Required

AUTH_ENABLED=true
BETTER_AUTH_SECRET=          # npx @better-auth/cli secret
BETTER_AUTH_URL=http://localhost:3000

BETTER_AUTH_URL must be the URL users actually reach the app under — it seeds better-auth's trusted origins, and a mismatch makes every login fail as a cross-origin request.

Interface mode

# unset — the default is true, which is what this deployment uses
NEXT_PUBLIC_ENABLE_EMPLOYEE_BASED_INTERFACE=true   # false = original shift-based UI

Leaving it unset keeps the employee-based interface on. Read at build time for the client bundle, so a container has to be rebuilt (or the value baked in) to switch modes for the browser. See Employee-based interface for what changes.

Repository (update check and changelog)

# unset by default — setting all three switches the update check on
REPO_OWNER=gabriel.gambel
REPO_NAME=Shiftplaner
REPO_API_URL=https://git.casa-due-pur.de/api/v1
REPO_WEB_URL=https://git.casa-due-pur.de   # optional, derived from REPO_API_URL
REPO_TOKEN=                                # only for a private repository

/api/version and /api/releases read releases from this repository. All of it is opt-in: with none of these set, no request leaves the container, the version is shown without a link and the changelog stays empty. That is the default in the published image and in the Helm chart, so a deployment someone else runs does not report on — or send traffic to — a repository they have nothing to do with.

Forgejo's release objects are field-compatible with GitHub's, so pointing these at a GitHub repository works too. REPO_WEB_URL is derived from REPO_API_URL when omitted. Without a token on a private repository the lookups return 404, which is handled — the UI simply shows no update.

Storage and networking

DATABASE_URL=/app/data/sqlite.db
UPLOADS_DIR=/app/data/uploads   # avatars; keep it on the same volume
SERVER_HOSTNAME=                # empty = dual-stack where available, "0.0.0.0" = IPv4 only

Optional: OAuth Providers

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

Optional: Custom OIDC

CUSTOM_OIDC_ENABLED=true
CUSTOM_OIDC_NAME=Company SSO
CUSTOM_OIDC_CLIENT_ID=
CUSTOM_OIDC_CLIENT_SECRET=
CUSTOM_OIDC_ISSUER=https://sso.example.com/.well-known/openid-configuration

Rate limits, session lifetimes and trusted origins are configurable as well; the upstream .env.example documents every variable with its default. The copy in this repository lists the variable names only.

Container images

Images are published to the Forgejo registry at git.casa-due-pur.de/gabriel.gambel/shiftplaner (note the lowercase path — the registry requires it even though the repository is spelled Shiftplaner):

Tag Description
latest Latest stable release
X.Y.Z, X.Y, X Specific version
dev Development build from main (unstable)

Building by hand:

docker build -f Dockerfile.k8s --build-arg VERSION=v2.2.1 -t git.casa-due-pur.de/gabriel.gambel/shiftplaner:2.2.1 .

CI lives in .forgejo/workflows/ and needs a registered Actions runner plus a REGISTRY_TOKEN repository secret with package write access.

Development

npm run dev            # dev server on 0.0.0.0:3000
npm run test           # lint + build + i18n — run before committing
npm run lint           # ESLint only
npx tsc --noEmit       # type check only
npm run i18n           # validate translations against de.json
npm run db:generate    # generate a migration after editing lib/db/schema.ts
npm run db:migrate     # apply migrations manually
npm run db:studio      # Drizzle Studio GUI

There is no unit test runner. messages/de.json is the source of truth for translations — add the German key first, then npm run i18n reports what is missing elsewhere.

Tech Stack

Layer Technology
Framework Next.js 16, React 19, TypeScript
Database SQLite, Drizzle ORM
Auth Better Auth
UI Tailwind CSS, shadcn/ui, Radix UI
i18n next-intl
Container Debian 12 minimal (node:22-bookworm-slim), non-root

Differences from upstream

Beyond the features above, these behavioural changes are worth knowing when comparing against the original:

Change Effect
New tables calendar_locations, absences, employee_calendar_settings; shifts gained location_id and user_id; calendars gained default start/end times The schema is ahead of upstream — migrating an upstream database forward works, migrating back does not
Migration history squashed into a single drizzle/0000_ordinary_psylocke.sql A database created by an older upstream version cannot replay this fork's history; export and re-import instead
.env.example reduced to bare variable names Use the upstream file as the reference for defaults and explanations
CI moved to .forgejo/workflows/ and builds Dockerfile.k8s The published image is the hardened one; nothing builds the original Dockerfile any more
Update check points at this repository No more notifications about upstream releases that this image does not contain

Support and contributing

For this fork — everything described in this README, the employee interface, the container image and the Kubernetes deployment:

Issues, merge requests git.casa-due-pur.de/gabriel.gambel/Shiftplaner
Maintainer gabriel.gambel

Run npm run test before committing.

For the original project — the links below belong to panteLx, not to this fork. They are the right place for bugs that also occur in upstream BetterShift, and the right place to support the original author's work. They are not a support channel for this repository, and panteLx has no involvement in or responsibility for the changes made here:

panteLx's channel Purpose
github.com/panteLx/BetterShift The original project
GitHub Issues Bugs that also exist upstream
Discord panteLx's community — not for this fork
Buy Me a Coffee Supports panteLx
GitHub Sponsors Supports panteLx

Before reporting something upstream, please check that it reproduces on the original project — most of what this README documents does not exist there.

Credits

BetterShift was created by panteLx and its contributors: github.com/panteLx/BetterShift. This fork builds on their work and keeps the MIT license.

License

MIT License, unchanged from upstream. See LICENSE for details.