feat: complete ShiftCraft — AI-powered shift scheduling SaaS

Complete implementation including:
- Landing page with hero, features, how-it-works, pricing
- Employee management (CRUD with soft delete)
- AI constraint parser (Anthropic Claude API)
- German labor law templates (ArbZG §3, §5, §9)
- HiGHS ILP solver for optimal fair schedules
- Schedule calendar result view (employee × date grid)
- Shift framework configuration (periods + shifts)
- Subscription tiers: Free / Pro / Business
- PocketBase setup script with collection creation + seed data
- .env.example with all required variables documented

Pages: employees, constraints (list/new/templates), schedules (list/new/[id]),
       settings (organization/shifts/billing), dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 07:47:31 +02:00
parent 2ea4ca5d52
commit 36e0946ee4
38 changed files with 4254 additions and 133 deletions

View File

@@ -0,0 +1,98 @@
import { getAnthropicClient } from '~/server/utils/anthropic'
import { getPBAdminClient } from '~/server/utils/pb-admin'
import type { ParsedConstraintResult, ConstraintJSON } from '~/shared/types/constraint'
export default defineEventHandler(async (event): Promise<ParsedConstraintResult> => {
const body = await readBody(event)
const { text, org_id } = body
if (!text || !org_id) {
throw createError({ statusCode: 400, message: 'text and org_id are required' })
}
const pb = await getPBAdminClient()
// Fetch employees and framework for context
const [employeesResult, frameworkResult] = await Promise.allSettled([
pb.collection('employees').getFullList({ filter: `org_id = "${org_id}" && active = true`, fields: 'id,name,roles' }),
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${org_id}"`),
])
const employees = employeesResult.status === 'fulfilled'
? employeesResult.value.map((e: { id: string; name: string; roles: string[] }) => ({ id: e.id, name: e.name, roles: e.roles }))
: []
const periods = frameworkResult.status === 'fulfilled'
? ((frameworkResult.value as { periods?: Array<{ id: string; name: string }> }).periods || [])
: []
const SYSTEM_PROMPT = `You are a scheduling constraint parser for a workforce management application called ShiftCraft.
Given free-text input from a manager, extract one or more scheduling constraints and output them as a JSON array.
AVAILABLE EMPLOYEES: ${JSON.stringify(employees)}
AVAILABLE SHIFT PERIODS: ${JSON.stringify(periods)}
CONSTRAINT TYPES YOU CAN USE:
- max_hours_per_day: params: {max_hours: number}
- max_hours_per_week: params: {max_hours: number}
- min_rest_between_shifts: params: {min_hours: number}
- max_consecutive_shifts: params: {max_count: number}
- max_consecutive_shift_type: params: {period_id: string, max_count: number}
- min_consecutive_days_off: params: {min_days: number}
- forbidden_shift_sequence: params: {first_period_id: string, second_period_id: string}
- employee_avoids_period: params: {period_id: string}
- employee_prefers_period: params: {period_id: string, prefer_count_per_week?: number}
- max_weekend_shifts_per_month: params: {max_count: number}
- fair_distribution: params: {metric: "total_shifts"|"night_shifts"|"weekend_shifts", max_deviation_percent: number}
RULES:
- Preference language ("doesn't like", "prefers", "mag keine", "bevorzugt") → hard: false, weight: 65
- Obligation language ("must not", "cannot", "never", "darf nicht") → hard: true
- Resolve employee names to their IDs from the list above. If ambiguous, use scope: {type: "global"}.
- Always include natural_language_summary in the same language as the input.
- Output ONLY a valid JSON array. No commentary.
OUTPUT FORMAT:
[
{
"type": "constraint_type",
"scope": {"type": "global"} | {"type": "employee", "employee_id": "..."} | {"type": "role", "role": "..."},
"params": {...},
"hard": true|false,
"weight": 1-100,
"natural_language_summary": "..."
}
]`
try {
const client = getAnthropicClient()
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
})
const content = response.content[0]
if (content.type !== 'text') throw new Error('Unexpected response type')
// Extract JSON from response
const jsonMatch = content.text.match(/\[[\s\S]*\]/)
if (!jsonMatch) throw new Error('No JSON array found in response')
const constraints: ConstraintJSON[] = JSON.parse(jsonMatch[0])
const ambiguities: string[] = []
// Validate basic structure
for (const c of constraints) {
if (!c.type || !c.scope || !c.params) {
ambiguities.push(`Unvollständige Bedingung erkannt: ${JSON.stringify(c)}`)
}
}
return { constraints, ambiguities }
} catch (err) {
console.error('Constraint parse error:', err)
throw createError({ statusCode: 500, message: `KI-Fehler: ${String(err)}` })
}
})

View File

@@ -0,0 +1,47 @@
import { getPBAdminClient } from '~/server/utils/pb-admin'
import { solveSchedule } from '~/server/utils/solver'
import type { SolveInput } from '~/shared/types/schedule'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { run_id } = body
if (!run_id) throw createError({ statusCode: 400, message: 'run_id required' })
const pb = await getPBAdminClient()
// Fetch the schedule run
const run = await pb.collection('schedule_runs').getOne(run_id)
// Update status to solving
await pb.collection('schedule_runs').update(run_id, { status: 'solving' })
try {
const input: SolveInput = {
organization_id: run.org_id as string,
period_start: run.period_start as string,
period_end: run.period_end as string,
framework: run.framework_snapshot as SolveInput['framework'],
employees: run.employees_snapshot as SolveInput['employees'],
constraints: (run.constraints_snapshot as SolveInput['constraints']) || [],
}
const result = await solveSchedule(input)
await pb.collection('schedule_runs').update(run_id, {
status: result.status,
result: result.assignments,
objective_value: result.objective_value,
solver_duration_ms: result.duration_ms,
infeasibility_hints: result.infeasibility_hints || [],
})
return result
} catch (err) {
await pb.collection('schedule_runs').update(run_id, {
status: 'error',
infeasibility_hints: [{ description: String(err) }],
})
throw err
}
})

14
server/utils/anthropic.ts Normal file
View File

@@ -0,0 +1,14 @@
import Anthropic from '@anthropic-ai/sdk'
let _client: Anthropic | null = null
export function getAnthropicClient(): Anthropic {
const config = useRuntimeConfig()
if (!config.anthropicApiKey) {
throw new Error('ANTHROPIC_API_KEY is not configured')
}
if (!_client) {
_client = new Anthropic({ apiKey: config.anthropicApiKey as string })
}
return _client
}

24
server/utils/pb-admin.ts Normal file
View File

@@ -0,0 +1,24 @@
import PocketBase from 'pocketbase'
let _adminClient: PocketBase | null = null
let _tokenExpiry: number = 0
export async function getPBAdminClient(): Promise<PocketBase> {
const config = useRuntimeConfig()
const pbUrl = config.public.pocketbaseUrl as string
if (!_adminClient) {
_adminClient = new PocketBase(pbUrl)
}
// Re-authenticate if token expired (PB tokens last 7 days but refresh at 1h)
if (!_adminClient.authStore.isValid || Date.now() > _tokenExpiry) {
await _adminClient.collection('_superusers').authWithPassword(
config.pbAdminEmail as string,
config.pbAdminPassword as string
)
_tokenExpiry = Date.now() + 3600 * 1000 // 1 hour
}
return _adminClient
}

View File

@@ -0,0 +1,32 @@
import type { SolveInput } from '~/shared/types/schedule'
interface FeasibilityIssue {
message: string
blocking: boolean
}
export function checkFeasibility(input: SolveInput): FeasibilityIssue[] {
const issues: FeasibilityIssue[] = []
const { employees, framework } = input
if (employees.length === 0) {
issues.push({ message: 'Keine Mitarbeiter vorhanden', blocking: true })
return issues
}
if (framework.shifts.length === 0) {
issues.push({ message: 'Keine Schichten definiert', blocking: true })
return issues
}
// Check if enough employees for the highest shift requirement
const maxRequired = Math.max(...framework.shifts.map(s => s.workers_required))
if (employees.length < maxRequired) {
issues.push({
message: `Zu wenige Mitarbeiter: ${maxRequired} benötigt, nur ${employees.length} vorhanden`,
blocking: true
})
}
return issues
}

View File

@@ -0,0 +1,69 @@
import type { SolveInput, SolveResult } from '~/shared/types/schedule'
import { buildModel } from './modelBuilder'
import { parseAssignments } from './resultParser'
import { checkFeasibility } from './feasibilityCheck'
export async function solveSchedule(input: SolveInput): Promise<SolveResult> {
const startTime = Date.now()
// Pre-solve sanity check
const issues = checkFeasibility(input)
if (issues.length > 0 && issues.some(i => i.blocking)) {
return {
status: 'infeasible',
assignments: [],
infeasibility_hints: issues.map(i => ({ description: i.message }))
}
}
try {
// Dynamic import — HiGHS WASM is large
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const HiGHS = (await import('highs' as any)).default
const highs = await HiGHS()
const model = buildModel(input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = highs.solve(model, {}) as Record<string, unknown>
const duration_ms = Date.now() - startTime
const status = result.Status as string
if (status === 'Infeasible' || status === 'Infeasible or Unbounded') {
return {
status: 'infeasible',
assignments: [],
duration_ms,
infeasibility_hints: [
{ description: 'Das Optimierungsproblem hat keine Lösung. Bitte überprüfen Sie Ihre Bedingungen, insbesondere ob genügend Mitarbeiter für alle Schichten verfügbar sind.' },
...issues.map(i => ({ description: i.message }))
]
}
}
if (status !== 'Optimal' && status !== 'Feasible') {
return {
status: 'error',
assignments: [],
duration_ms,
infeasibility_hints: [{ description: `Solver Status: ${status}` }]
}
}
const assignments = parseAssignments(result, input)
return {
status: 'solved',
assignments,
objective_value: result.ObjectiveValue as number,
duration_ms,
}
} catch (err) {
console.error('Solver error:', err)
return {
status: 'error',
assignments: [],
infeasibility_hints: [{ description: String(err) }]
}
}
}

View File

@@ -0,0 +1,341 @@
import type { SolveInput } from '~/shared/types/schedule'
import { getDaysInRange, getWeekday } from '~/app/utils/dateHelpers'
import type { ConstraintJSON } from '~/shared/types/constraint'
export function buildModel(input: SolveInput) {
const { employees, framework, period_start, period_end, constraints } = input
const days = getDaysInRange(period_start, period_end)
const E = employees.length
const S = framework.shifts.length
const D = days.length
// Variable naming: x_e_s_d = 1 if employee e works shift s on day d
const varNames: string[] = []
const varIndex: Record<string, number> = {}
for (let e = 0; e < E; e++) {
for (let s = 0; s < S; s++) {
for (let d = 0; d < D; d++) {
const name = `x_${e}_${s}_${d}`
varIndex[name] = varNames.length
varNames.push(name)
}
}
}
// Penalty variables for soft constraints
const penaltyVarStart = varNames.length
let penaltyVarCount = 0
const softConstraintPenalties: Array<{ varIdx: number; weight: number }> = []
function addPenaltyVar(weight: number): number {
const idx = penaltyVarStart + penaltyVarCount++
varNames.push(`penalty_${idx}`)
softConstraintPenalties.push({ varIdx: idx, weight })
return idx
}
// Objective: minimize sum of soft penalties
const objectiveCoeffs: Record<string, number> = {}
for (const p of softConstraintPenalties) {
objectiveCoeffs[varNames[p.varIdx]] = p.weight
}
const bounds: Record<string, [number, number]> = {}
const binaries = new Set<string>()
for (const name of varNames) {
bounds[name] = [0, 1]
if (name.startsWith('x_')) binaries.add(name)
}
const rows: Array<{
name: string
vars: Record<string, number>
lb: number
ub: number
}> = []
let rowIdx = 0
function addRow(vars: Record<string, number>, lb: number, ub: number) {
rows.push({ name: `r${rowIdx++}`, vars, lb, ub })
}
// Structural constraint 1: at most 1 shift per employee per day
for (let e = 0; e < E; e++) {
for (let d = 0; d < D; d++) {
const vars: Record<string, number> = {}
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${d}`] = 1
}
addRow(vars, 0, 1)
}
}
// Structural constraint 2: coverage requirements
for (let s = 0; s < S; s++) {
const shift = framework.shifts[s]
for (let d = 0; d < D; d++) {
const weekday = getWeekday(days[d])
const applicable = shift.days_applicable.length === 0 || shift.days_applicable.includes(weekday)
if (!applicable) {
// Force all x_e_s_d = 0 for this day
for (let e = 0; e < E; e++) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
continue
}
const coverageVars: Record<string, number> = {}
for (let e = 0; e < E; e++) {
coverageVars[`x_${e}_${s}_${d}`] = 1
}
addRow(coverageVars, shift.workers_required, E)
}
}
// Structural constraint 3: availability
for (let e = 0; e < E; e++) {
const emp = employees[e]
for (let d = 0; d < D; d++) {
const date = days[d]
// Block unavailable dates
if (emp.unavailable_dates.includes(date)) {
for (let s = 0; s < S; s++) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
continue
}
// Block periods not available
for (let s = 0; s < S; s++) {
const shift = framework.shifts[s]
const period = framework.periods.find(p => p.id === shift.period_id)
if (period && emp.available_periods.length > 0 && !emp.available_periods.includes(period.id)) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
}
}
}
// Process constraints from ConstraintJSON
for (const c of constraints) {
const cj = c.constraint_json as ConstraintJSON
const params = cj.params as Record<string, unknown>
// Determine affected employees
let affectedEmployees: number[] = Array.from({ length: E }, (_, i) => i)
if (cj.scope.type === 'employee') {
const empIdx = employees.findIndex(e => e.id === (cj.scope as { type: 'employee'; employee_id: string }).employee_id)
if (empIdx >= 0) affectedEmployees = [empIdx]
else continue
} else if (cj.scope.type === 'role') {
const role = (cj.scope as { type: 'role'; role: string }).role
affectedEmployees = employees
.map((e, i) => ({ e, i }))
.filter(({ e }) => e.roles.includes(role))
.map(({ i }) => i)
}
switch (cj.type) {
case 'max_consecutive_shifts': {
const max = params.max_count as number
for (const e of affectedEmployees) {
for (let d = 0; d <= D - (max + 1); d++) {
const vars: Record<string, number> = {}
for (let dd = d; dd <= d + max; dd++) {
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${dd}`] = 1
}
}
addRow(vars, 0, max)
}
}
break
}
case 'max_consecutive_shift_type': {
const max = params.max_count as number
const periodId = params.period_id as string
const shiftIndices = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === periodId)
.map(({ i }) => i)
if (shiftIndices.length === 0) break
for (const e of affectedEmployees) {
for (let d = 0; d <= D - (max + 1); d++) {
const vars: Record<string, number> = {}
for (let dd = d; dd <= d + max; dd++) {
for (const s of shiftIndices) {
vars[`x_${e}_${s}_${dd}`] = 1
}
}
addRow(vars, 0, max)
}
}
break
}
case 'forbidden_shift_sequence': {
const firstPeriod = params.first_period_id as string
const secondPeriod = params.second_period_id as string
const firstShifts = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === firstPeriod)
.map(({ i }) => i)
const secondShifts = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === secondPeriod)
.map(({ i }) => i)
for (const e of affectedEmployees) {
for (let d = 0; d < D - 1; d++) {
for (const s1 of firstShifts) {
for (const s2 of secondShifts) {
addRow({
[`x_${e}_${s1}_${d}`]: 1,
[`x_${e}_${s2}_${d + 1}`]: 1
}, 0, 1)
}
}
}
}
break
}
case 'employee_avoids_period': {
const periodId = params.period_id as string
const shiftIndices = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === periodId)
.map(({ i }) => i)
if (cj.hard) {
for (const e of affectedEmployees) {
for (let d = 0; d < D; d++) {
for (const s of shiftIndices) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
}
}
} else {
// Soft: penalty per assignment
const weight = (c.weight || 70) * 10
for (const e of affectedEmployees) {
for (let d = 0; d < D; d++) {
for (const s of shiftIndices) {
const pv = addPenaltyVar(weight)
objectiveCoeffs[varNames[pv]] = weight
bounds[varNames[pv]] = [0, 1]
addRow({
[varNames[pv]]: 1,
[`x_${e}_${s}_${d}`]: -1
}, 0, 1)
}
}
}
}
break
}
case 'max_weekend_shifts_per_month': {
const max = params.max_count as number
const weekendDays = days
.map((day, i) => ({ day, i }))
.filter(({ day }) => {
const wd = getWeekday(day)
return wd === 5 || wd === 6
})
.map(({ i }) => i)
for (const e of affectedEmployees) {
const vars: Record<string, number> = {}
for (const d of weekendDays) {
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${d}`] = 1
}
}
if (Object.keys(vars).length > 0) {
addRow(vars, 0, max)
}
}
break
}
case 'min_rest_between_shifts':
// Already handled implicitly by "max 1 shift per day"
break
default:
break
}
}
// Rebuild objective with finalized penalty vars
for (const p of softConstraintPenalties) {
objectiveCoeffs[varNames[p.varIdx]] = p.weight
}
// Initialize bounds for penalty vars
for (let i = penaltyVarStart; i < varNames.length; i++) {
if (!bounds[varNames[i]]) {
bounds[varNames[i]] = [0, 1]
}
}
return {
name: 'ShiftCraft',
sense: 'minimize' as const,
offset: 0,
col_lower: varNames.map(n => bounds[n]?.[0] ?? 0),
col_upper: varNames.map(n => bounds[n]?.[1] ?? 1),
col_cost: varNames.map(n => objectiveCoeffs[n] ?? 0),
col_names: varNames,
row_lower: rows.map(r => r.lb),
row_upper: rows.map(r => r.ub),
row_names: rows.map(r => r.name),
a_matrix: {
format: 'colwise' as const,
start: buildColwiseStart(varNames, rows),
index: buildColwiseIndex(varNames, rows),
value: buildColwiseValues(varNames, rows),
},
integrality: varNames.map(n => binaries.has(n) ? 1 : 0),
}
}
function buildColwiseStart(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const starts: number[] = [0]
let count = 0
for (const name of varNames) {
for (const row of rows) {
if (name in row.vars) count++
}
starts.push(count)
}
return starts
}
function buildColwiseIndex(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const indices: number[] = []
for (const name of varNames) {
rows.forEach((row, i) => {
if (name in row.vars) indices.push(i)
})
}
return indices
}
function buildColwiseValues(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const values: number[] = []
for (const name of varNames) {
for (const row of rows) {
if (name in row.vars) values.push(row.vars[name])
}
}
return values
}

View File

@@ -0,0 +1,38 @@
import type { SolveInput, ShiftAssignment } from '~/shared/types/schedule'
import { getDaysInRange } from '~/app/utils/dateHelpers'
export function parseAssignments(result: Record<string, unknown>, input: SolveInput): ShiftAssignment[] {
const { employees, framework, period_start, period_end } = input
const days = getDaysInRange(period_start, period_end)
const E = employees.length
const S = framework.shifts.length
const D = days.length
const assignments: ShiftAssignment[] = []
const solution = result.Columns as Record<string, { Primal: number }>
for (let e = 0; e < E; e++) {
for (let s = 0; s < S; s++) {
for (let d = 0; d < D; d++) {
const varName = `x_${e}_${s}_${d}`
const value = solution[varName]?.Primal ?? 0
if (value > 0.5) {
const shift = framework.shifts[s]
const period = framework.periods.find(p => p.id === shift.period_id)
assignments.push({
employee_id: employees[e].id,
employee_name: employees[e].name,
shift_id: shift.id,
shift_name: shift.name,
period_id: shift.period_id,
date: days[d],
start_time: period?.start_time ?? '00:00',
end_time: period?.end_time ?? '00:00',
})
}
}
}
}
return assignments
}