- TypeScript 99.4%
- CSS 0.2%
- Dockerfile 0.2%
- JavaScript 0.1%
- Go Template 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .github | ||
| app | ||
| charts/shiftplaner | ||
| components | ||
| demo | ||
| docker | ||
| docs | ||
| drizzle | ||
| hooks | ||
| k8s | ||
| lib | ||
| messages | ||
| public | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| components.json | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.k8s | ||
| drizzle.config.ts | ||
| eslint.config.mjs | ||
| instrumentation.ts | ||
| LICENSE | ||
| metadata.json | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| proxy.ts | ||
| README.md | ||
| tsconfig.json | ||
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.
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)
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,pendingorrejected— 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 setsHOSTNAMEto the pod name in every container — probes on localhost then fail.docker/entrypoint.jsoverrides 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, Kustomizationcharts/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,
Recreatestrategy, 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/datacarries the database and the avatar uploads./tmpand/app/.next/cacheare 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.