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:
32
server/utils/solver/feasibilityCheck.ts
Normal file
32
server/utils/solver/feasibilityCheck.ts
Normal 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
|
||||
}
|
||||
69
server/utils/solver/index.ts
Normal file
69
server/utils/solver/index.ts
Normal 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) }]
|
||||
}
|
||||
}
|
||||
}
|
||||
341
server/utils/solver/modelBuilder.ts
Normal file
341
server/utils/solver/modelBuilder.ts
Normal 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
|
||||
}
|
||||
38
server/utils/solver/resultParser.ts
Normal file
38
server/utils/solver/resultParser.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user