npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devolta/rostersolver-sdk

v0.4.0

Published

Client für den RosterSolver — prüft und plant Dienstpläne nach ArG, L-GAV Gastgewerbe, AZG und ArbZG (CH, AT, DE).

Downloads

644

Readme

@devolta/rostersolver-sdk

Client für den RosterSolver — den Dienst, der Dienstpläne prüft (check) und plant (generate). Länder CH, AT und DE.

  • Keine Laufzeitabhängigkeiten, nur das globale fetch von Node 18+
  • Vollständige TypeScript-Typen für Anfrage und Antwort
  • Geprüft wird nach Arbeitsgesetz und L-GAV Gastgewerbe: Tagesspanne, Ruhetage, Feiertage, freie Sonntage, Nachtarbeit, Überzeit
  • Fehlerklassen statt Zeichenkettenvergleiche
  • Zeitgrenzen nach Spezifikation: 5 s für check, 20 s für generate
  • safeSolve() für den Rückfallpfad, der nie wirft
npm install @devolta/rostersolver-sdk

Loslegen

import { RosterSolverClient } from '@devolta/rostersolver-sdk'

const solver = new RosterSolverClient({
  baseUrl: process.env.ROSTERSOLVER_URL ?? 'https://rostersolver.devolta.de',
  apiKey: process.env.ROSTERSOLVER_KEY!,
})

const plan = await solver.generate({
  timezone: 'Europe/Zurich',
  rule_pack: {
    country_code: 'CH',
    pack_id: 'CH_GASTRO_LGAV',
    business_type: 'seasonal',        // 42 / 43,5 / 45 Stunden
  },
  // Ohne planning_window hält der Dienst jede Woche für angeschnitten, in der
  // niemand am Sonntag arbeitet — Ruhetage und freie Sonntage werden dann
  // nicht geprüft.
  planning_window: { starts_on: '2026-09-14', ends_on: '2026-09-27' },
  holidays: [{ holiday_date: '2026-09-21', name: 'Bettag' }],
  employees: [
    {
      id: 'a1b2…',
      is_minor: false,
      employment_status: 'active',
      weekly_hours: 43.5,
      has_family_duties: true,
      cost_per_minute: 0.5,
      home_restaurant_id: 'r1',
      qualifications: [{ qualification_id: 'service', level: 'senior', level_rank: 3 }],
      // Was vor dem Zeitraum liegt. Ohne diese Angaben beginnt jede Anfrage
      // bei null: der Solver sieht nicht, dass gestern bis Mitternacht
      // gearbeitet wurde.
      history: {
        consecutive_work_days_before: 3,
        last_shift_end: '2026-09-13T23:30:00+02:00',
        free_sundays_ytd: 2,
        overtime_balance_minutes: 4200,
      },
    },
  ],
  shifts: [
    {
      id: 's1',
      restaurant_id: 'r1',
      shift_date: '2026-09-14',
      starts_at: '2026-09-14T09:00:00+02:00',
      ends_at: '2026-09-14T17:00:00+02:00',
      break_minutes: 30,
      headcount: 1,
      requirements: [
        { qualification_id: 'service', min_level_rank: 1, headcount: 1, severity: 'hard' },
      ],
    },
  ],
  objective: { weight_coverage: 100, weight_wishes: 60 },
})

for (const assignment of plan.assignments) {
  console.log(assignment.shift_id, '→', assignment.employee_id, assignment.score)
  for (const reason of assignment.reasons) console.log('  ', reason.code, reason.detail)
}

mode und contract_version setzt der Client selbst.

Jugendliche brauchen zusätzlich minor_age_band: bis 16 Jahren endet der Arbeitstag um 20 Uhr, darüber um 22 Uhr (ArG Art. 31 Abs. 2).

Was geprüft wird

Über die fünf Grundwerte — Tages- und Wochenarbeitszeit, Ruhezeiten, Pausen, Arbeitstage in Folge — hinaus: Tagesspanne von 14 Stunden, herabgesetzte Ruhezeit, Nachtschichtgrenzen, zwei Ruhetage pro Woche mit einem ganzen darunter, ein freier Sonntag innert zweier Wochen, Feiertage als Sonntage, Endzeiten für Jugendliche, Überzeit und Überstundensaldo.

Regelpakete

| pack_id | Grundlage | | --- | --- | | CH_ARG | Arbeitsgesetz, Jugendliche nach ArGV 5 | | CH_GASTRO_LGAV | L-GAV Gastgewerbe Art. 15/16 auf dem ArG | | AT_AZG | AZG / ARG, Jugendliche nach KJBG | | DE_ARBZG | ArbZG, Jugendliche nach JArbSchG |

Ohne pack_id gilt das gesetzliche Grundpaket des Landes. business_type gilt nur beim L-GAV.

Jeder Verstoss trägt seine Fundstelle:

for (const v of plan.violations) {
  console.log(v.code, v.legal_reference, v.message)
  // MISSING_REST_DAYS  L-GAV Art. 16 Ziff. 1  Nur 1 statt 2 Ruhetage in der Woche
  // MAX_WORK_SPAN      ArG Art. 10 Abs. 3     Arbeitsspanne zu lang: 900 statt …
}

Der Rückfallpfad

Ein Ausfall des Solvers darf die Schichtplanung nie blockieren — so steht es in der Spezifikation, und safeSolve() bildet genau das ab. Es wirft nicht, sondern meldet, dass zurückgefallen werden soll:

const result = await solver.safeSolve({ mode: 'check', ...payload })

if (result.ok) {
  await speichereEntwurf(result.response)
} else {
  // Zeitüberschreitung, 503, Netzwerkfehler, Schemafehler — alles landet hier.
  console.warn('RosterSolver nicht verfügbar:', result.error.code, result.error.message)
  await pruefeMitEingebauterRegelpruefung(payload)
}

Was offenbleibt, und warum

unfilled[] ist der aussagekräftigste Teil der Antwort: er sagt, ob eingestellt, umgeplant oder eine Qualifikation nachgeschult werden muss.

for (const gap of plan.unfilled) {
  console.log(`${gap.shift_id}: ${gap.missing_headcount} offen (${gap.reason})`)
  for (const candidate of gap.candidates_rejected) {
    console.log(`   ${candidate.employee_id}: ${candidate.reason} — ${candidate.detail ?? ''}`)
  }
}

Häufige Gründe: QUALIFICATION_MISSING, QUALIFICATION_EXPIRED, ABSENCE_CONFLICT, YOUTH_NIGHT_WORK, MIN_DAILY_REST, ASSIGNED_ELSEWHERE.

Prüfen statt planen

const geprüft = await solver.check({
  timezone: 'Europe/Zurich',
  rule_pack: { country_code: 'DE', overrides: { maxWeeklyWorkMinutes: 2400 } },
  planning_window: { starts_on: '2026-09-14', ends_on: '2026-09-20' },
  employees,
  shifts: shifts.map((s) => ({ ...s, employee_id: zuteilung[s.id] })),
})

if (!geprüft.ok) {
  for (const v of geprüft.violations.filter((v) => v.severity === 'error')) {
    console.error(v.code, v.message, v.legal_reference)
  }
}

Abweichungen (overrides) dürfen schärfen, nicht lockern. Ein Versuch, eine gesetzliche Grenze zu lockern, endet in einem RulePackOverrideRejectedError. Welche Schlüssel angenommen werden und in welche Richtung sie verschoben werden dürfen, steht maschinenlesbar in (await solver.contract()).override_keys — damit prüft die Oberfläche vorab, statt den Fehler abzuwarten.

Beim Prüfen erfasster Zeiten sind restaurant_id, headcount und qualifications entbehrlich: eine Stempelung hat nicht immer einen Betriebsbezug, und geprüft wird die Zeit, nicht der Ort. shift_date ist der fachliche Schichttag in der Betriebszeitzone — bei einer Schicht über Mitternacht der Tag des Beginns, nicht der UTC-Kalendertag.

const zeiten = await solver.check({
  timezone: 'Europe/Zurich',
  rule_pack: { country_code: 'CH', pack_id: 'CH_GASTRO_LGAV' },
  planning_window: { starts_on: '2026-08-01', ends_on: '2026-08-31' },
  employees: erfasste.map((e) => ({ id: e.id, is_minor: e.is_minor, employment_status: 'active' })),
  shifts: eintraege.map((e) => ({
    id: e.id,
    employee_id: e.employee_id,
    shift_date: e.shift_date,          // fachlicher Tag, Ortszeit
    starts_at: e.started_at,
    ends_at: e.ended_at,               // nur abgeschlossene Einträge
    break_minutes: e.break_minutes,    // tatsächlich genommene Pause
  })),
  options: { explain: true },
})

for (const v of zeiten.violations) {
  console.error(v.code, v.legal_reference, v.explain?.measure, v.actual, '>', v.limit)
}

Optionen

| Option | Standard | Bedeutung | | --- | --- | --- | | baseUrl | — | Basis-URL des Dienstes | | apiKey | — | Wert für X-Solver-Key | | timeoutMs | 5 000 (check) / 20 000 (generate) | Überschreibt die Voreinstellung | | retries | 1 | Wiederholungen — nur bei 503 und Netzwerkfehlern, nie bei 4xx | | retryDelayMs | 250 | Grundwartezeit, verdoppelt sich je Versuch | | fetch | global | Eigene Implementierung, etwa für einen Proxy | | headers | {} | Zusätzliche Header, etwa zur Ablaufverfolgung |

Jeder Aufruf nimmt zusätzlich ein AbortSignal:

const controller = new AbortController()
setTimeout(() => controller.abort(), 3_000)
await solver.generate(payload, { signal: controller.signal })

Fehler

Alle Fehler erben von RosterSolverError und tragen code, httpStatus, details und requestId.

| Klasse | Code | HTTP | | --- | --- | --- | | InvalidRequestError | INVALID_REQUEST | 400 | | UnsupportedContractVersionError | UNSUPPORTED_CONTRACT_VERSION | 400 | | UnsupportedCountryError | UNSUPPORTED_COUNTRY | 400 | | RulePackOverrideRejectedError | RULE_PACK_OVERRIDE_REJECTED | 422 | | ProblemTooLargeError | PROBLEM_TOO_LARGE | 413 | | RateLimitedError | RATE_LIMITED | 429 | | UnauthorizedError | UNAUTHORIZED | 401 | | SolverUnavailableError | SOLVER_UNAVAILABLE | 503 | | SolverTimeoutError | TIMEOUT | — | | SolverNetworkError | NETWORK | — | | MalformedResponseError | MALFORMED_RESPONSE | — |

error.retryable sagt, ob ein zweiter Versuch lohnt. error.shouldFallback ist immer true — die manuelle Planung muss in jedem Fall bedienbar bleiben. error.retryAfterSeconds steht bei 429 und 503; der Client hält sich beim Wiederholen daran, statt blind nach 250 ms erneut zu fragen.

Weitere Endpunkte

await solver.health()     // Bereitschaft und rules_version, auch ohne gültigen Schlüssel
await solver.contract()   // Modi, Länder, Pakete, alle Codes, angenommene Abweichungen
await solver.rulepacks()  // Alle Regelpakete mit sämtlichen Grundwerten

rulepacks() ist die Quelle für die Oberfläche: die geprüften Schwellen anzeigen, ohne sie zu duplizieren.

const { rulepacks } = await solver.rulepacks()
const lgav = rulepacks.find((p) => p.pack_id === 'CH_GASTRO_LGAV')!
lgav.adult.restDays.daysPerWeek            // 2
lgav.adult.restDays.extendedStreakRestMinutes  // 4980 (83 Stunden)
lgav.adult.maxWorkSpanMinutes              // 840 (14 Stunden)
lgav.adult.night.maxDailyWorkMinutes       // 540 (9 Stunden)

Zuschlagsfenster

payroll steht neben night, nicht darin: night ist die geprüfte Rechtsgrenze, payroll das Fenster für den Lohnzuschlag. Sie fallen nur in der Schweiz zusammen.

const de = rulepacks.find((p) => p.pack_id === 'DE_ARBZG')!
de.adult.night.fromHour                    // 20 — geprüfte Nachtarbeit
de.payroll.nightSupplementFromHour         // 20 — § 3b EStG
de.payroll.sundaySupplementApplies         // true

Pausenstaffel

const pause = await solver.breaks({ country_code: 'CH', worked_minutes: 570 })
pause.required_break_minutes               // 60
pause.applied_rule                         // { after_minutes: 540, min_break_minutes: 60 }
pause.selection_rule                       // die Auswahlregel im Klartext

Es gilt die höchste Stufe, deren Schwelle die Nettoarbeitszeit echt überschreitet; Stufen kumulieren nicht. Wer overrides speichert, schickt sie mit — sonst antwortet die Zeiterfassung mit einer anderen Staffel als die Prüfung.

Zwischenspeicher billig halten

const erste = await solver.rulepacksCached()
// … später, mit dem gemerkten ETag:
const wieder = await solver.rulepacksCached({ etag: erste.etag })
if (wieder.notModified) {
  // Der eigene Stand gilt weiter — es wurde nichts übertragen.
}

rulesVersion steht in beiden Fällen dabei; sie steigt bei jeder Änderung an den Paketen. Wer nur wissen will, ob sich etwas geändert hat, fragt health() — das geht ohne Schlüssel und ohne Kontingent.

Regelstand für einen Ausfall

Fällt der Dienst aus, ist ein Plan ungeprüft. Pausensetzung und Kostenrechnung sollen deswegen nicht stillstehen:

import { verifySnapshot } from '@devolta/rostersolver-sdk'

const stand = await solver.rulepacksSnapshot()
if (!verifySnapshot(stand, process.env.SNAPSHOT_SIGNING_KEY!)) {
  throw new Error('Regelstand nicht vertrauenswürdig')
}
// ausliefern und beim Start laden — Grundwerte, kein Prüfalgorithmus

Grenzen

  • generate: höchstens 500 Mitarbeitende und 2000 Schichten je Anfrage. check: 2000 und 20 000 — die Prüfung rechnet ohne Modell, eine Monatsprüfung passt in einen Lauf. Darüber ProblemTooLargeError
  • violations[] trägt höchstens 5000 Einträge; meta.violations_total nennt die tatsächliche Zahl, ok wird vor dem Kürzen bestimmt
  • meta.truncated: true heisst: die Lösung ist gültig, aber nicht beweisbar optimal — bei grossen Anfragen der Normalfall
  • Regeln, die eine Vorgeschichte brauchen — Überzeit im Jahr, Überstundensaldo, freie Sonntage —, werden gemeldet, aber nicht eingeplant
  • assignments[] ist ein Vorschlag, keine Buchung

Entwicklung

npm install
npm run typecheck
npm test

# gegen einen laufenden Dienst
ROSTERSOLVER_URL=https://rostersolver.devolta.de \
ROSTERSOLVER_KEY=… \
npm run test:integration

Lizenz

MIT © devolta UG