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

@forjakit/scheduling

v0.1.1

Published

Framework-agnostic scheduling engine for any domain that needs time-based event management. Handles sports matches, service appointments, room bookings, and anything else that involves scheduling participants at specific times.

Readme

@forjakit/scheduling

Framework-agnostic scheduling engine for any domain that needs time-based event management. Handles sports matches, service appointments, room bookings, and anything else that involves scheduling participants at specific times.

The core package contains zero framework or database dependencies. It defines the business logic, validation schemas, status lifecycle, and conflict detection. Storage and HTTP are handled by companion adapter packages.

Installation

pnpm add @forjakit/scheduling

Peer dependency: zod >= 3.24.

Core Concepts

ScheduledEvent

The atomic unit of the module. Every scheduled event has:

| Field | Type | Description | | ---------------- | ------------------ | ------------------------------------------------------- | | id | string | Unique identifier (assigned by storage layer) | | tenantId | string | Tenant isolation key | | contextId | string | Groups related events (e.g., championship ID, business ID) | | participants | Participant[] | 1..N participants involved in the event | | scheduledAt | Date | Start time | | durationMinutes| number | Duration used for end-time calculation and overlap detection | | venue | Venue \| null | Optional location | | status | EventStatus | Lifecycle state | | metadata | TMeta | Opaque consumer-defined data | | createdAt | Date | Creation timestamp | | updatedAt | Date | Last update timestamp |

Participant

A generic participant in an event. The module never interprets the semantic -- the consumer defines what a participant means.

interface Participant {
  id: string;    // External ID from your domain (team ID, user ID, etc.)
  label: string; // Display label for listings and conflict messages
  type: string;  // Consumer-defined tag: "team", "player", "professional", "client"
}

Venue

An optional location where the event takes place.

interface Venue {
  id: string;
  label: string;
  type?: string; // "court", "room", "chair", etc.
}

Metadata Extension Pattern

Every consumer defines their own metadata shape via a Zod schema. The core module stores and retrieves it without inspecting it. The TMeta generic flows through the entire system -- service, storage, and schemas are all parameterized by it.

// Sports domain
const matchMeta = z.object({
  round: z.number().int().positive(),
  group: z.string().optional(),
});

// Appointment domain
const appointmentMeta = z.object({
  serviceType: z.string(),
  price: z.number().positive(),
  notes: z.string().optional(),
});

Creating a Service

import { createSchedulingService } from "@forjakit/scheduling";
import { z } from "zod";

const metadataSchema = z.object({
  round: z.number().int().positive(),
  group: z.string().optional(),
});

const service = createSchedulingService({
  storage: myStorageAdapter, // implements SchedulingStorage<TMeta>
  metadataSchema,
  conflictDetection: { enabled: true }, // default: true
  availability: {                       // optional, for appointment use cases
    storage: myAvailabilityStorage,
    defaultSlotDuration: 30,
  },
});

SchedulingServiceConfig

| Field | Type | Required | Default | Description | | ------------------- | ------------------------------ | -------- | ------- | ------------------------------------------ | | storage | SchedulingStorage<TMeta> | Yes | -- | Storage adapter for events | | metadataSchema | ZodTypeAny | Yes | -- | Zod schema for consumer metadata | | conflictDetection | { enabled: boolean } | No | { enabled: true } | Toggle participant conflict detection | | availability | { storage, defaultSlotDuration? } | No | -- | Enables availability window management |

Schema Factory

createSchedulingSchemas() produces Zod schemas composed with your metadata schema. The service creates these internally, but you can also use them standalone for validation.

import { createSchedulingSchemas } from "@forjakit/scheduling";
import { z } from "zod";

// Sports match schemas
const schemas = createSchedulingSchemas(
  z.object({
    round: z.number().int().positive(),
    group: z.string().optional(),
  })
);

// Appointment schemas
const schemas = createSchedulingSchemas(
  z.object({
    serviceType: z.string(),
    price: z.number().positive(),
  })
);

Produced Schemas

| Schema | Description | | ---------------------- | ----------------------------------------------- | | CreateEventInput | Full creation payload with metadata | | UpdateEventInput | Partial update (all fields optional) | | TransitionStatusInput| { status: EventStatus } | | EventFiltersInput | Query filters for listing events | | AvailabilityInput | Availability window input | | ParticipantSchema | Participant validation | | VenueSchema | Venue validation | | EventStatusSchema | Enum: SCHEDULED, IN_PROGRESS, FINISHED, CANCELLED|

Service API

All methods validate input through Zod schemas before executing business logic.

scheduleEvent(input)

Creates a new scheduled event. Runs conflict detection if enabled.

  • Params: CreateEventInput -- tenantId, contextId, participants (min 1), scheduledAt, durationMinutes (default 60), venue (default null), metadata.
  • Returns: Promise<ScheduledEvent<TMeta>>
  • Throws: SchedulingError with code PARTICIPANT_CONFLICT if any participant has an overlapping event.
const event = await service.scheduleEvent({
  tenantId: "tenant-1",
  contextId: "championship-abc",
  participants: [
    { id: "team-1", label: "Eagles", type: "team" },
    { id: "team-2", label: "Hawks", type: "team" },
  ],
  scheduledAt: new Date("2026-04-01T14:00:00Z"),
  durationMinutes: 90,
  venue: { id: "court-1", label: "Main Court", type: "court" },
  metadata: { round: 1, group: "A" },
});

getEvent(id, tenantId)

Retrieves a single event by ID within a tenant.

  • Params: id: string, tenantId: string
  • Returns: Promise<ScheduledEvent<TMeta>>
  • Throws: SchedulingError with code EVENT_NOT_FOUND (404).

updateEvent(id, tenantId, input)

Updates mutable fields of a SCHEDULED or IN_PROGRESS event. Re-runs conflict detection if scheduledAt or durationMinutes changed.

  • Params: id: string, tenantId: string, UpdateEventInput -- scheduledAt?, durationMinutes?, venue?, metadata?.
  • Returns: Promise<ScheduledEvent<TMeta>>
  • Throws:
    • EVENT_NOT_FOUND (404) if the event does not exist.
    • EVENT_NOT_MODIFIABLE (409) if the event is FINISHED or CANCELLED.
    • PARTICIPANT_CONFLICT (409) if the new time overlaps another event (excludes the event being updated).

transitionStatus(id, tenantId, nextStatus)

Transitions an event's status following the allowed lifecycle.

  • Params: id: string, tenantId: string, nextStatus: EventStatus
  • Returns: Promise<ScheduledEvent<TMeta>>
  • Throws:
    • EVENT_NOT_FOUND (404).
    • INVALID_TRANSITION (409) if the transition is not allowed.

cancelEvent(id, tenantId)

Convenience method that calls transitionStatus(id, tenantId, "CANCELLED").

  • Returns: Promise<ScheduledEvent<TMeta>>
  • Throws: Same as transitionStatus.

deleteEvent(id, tenantId)

Permanently deletes an event. Only SCHEDULED events can be deleted.

  • Params: id: string, tenantId: string
  • Returns: Promise<void>
  • Throws:
    • EVENT_NOT_FOUND (404).
    • EVENT_NOT_MODIFIABLE (409) if the event is not in SCHEDULED status.

listEvents(filters)

Lists events matching the given filters. All filters are AND-combined.

  • Params: EventFiltersInput -- tenantId (required), contextId?, participantId?, status? (single or array), from?, to?.
  • Returns: Promise<ScheduledEvent<TMeta>[]>
const events = await service.listEvents({
  tenantId: "tenant-1",
  contextId: "championship-abc",
  status: ["SCHEDULED", "IN_PROGRESS"],
  from: new Date("2026-04-01"),
  to: new Date("2026-04-30"),
});

setAvailability(input)

Sets a recurring availability window for a participant. Upserts by (participantId, tenantId, dayOfWeek).

  • Params: AvailabilityInput -- participantId, participantType, tenantId, dayOfWeek (0-6, Sunday=0), startTime ("HH:MM"), endTime ("HH:MM").
  • Returns: Promise<void>
  • Throws: FEATURE_NOT_CONFIGURED (500) if availability was not configured.
await service.setAvailability({
  participantId: "barber-1",
  participantType: "professional",
  tenantId: "tenant-1",
  dayOfWeek: 1, // Monday
  startTime: "09:00",
  endTime: "18:00",
});

getAvailableSlots(participantId, tenantId, date, durationMinutes?)

Computes available time slots for a participant on a given date. Generates candidate slots from availability windows, then subtracts existing events.

  • Params: participantId: string, tenantId: string, date: Date, durationMinutes?: number (defaults to configured default or 60).
  • Returns: Promise<TimeSlot[]> -- each slot has startsAt, endsAt, participantId, venueId.
  • Throws: FEATURE_NOT_CONFIGURED (500) if availability was not configured.
const slots = await service.getAvailableSlots(
  "barber-1",
  "tenant-1",
  new Date("2026-04-07"), // a Monday
  30 // 30-minute slots
);
// [{ startsAt: ..., endsAt: ..., participantId: "barber-1", venueId: null }, ...]

schemas

The service also exposes the composed Zod schemas via service.schemas for external use (e.g., route validation).

Status Lifecycle

SCHEDULED ──→ IN_PROGRESS ──→ FINISHED
    │              │
    └──→ CANCELLED ←┘

Transition Rules

| From | Allowed Targets | | ------------- | ---------------------------- | | SCHEDULED | IN_PROGRESS, CANCELLED | | IN_PROGRESS | FINISHED, CANCELLED | | FINISHED | (terminal -- no transitions) | | CANCELLED | (terminal -- no transitions) |

All events start as SCHEDULED. Terminal states (FINISHED, CANCELLED) cannot be transitioned. Attempting an invalid transition throws INVALID_TRANSITION (409).

Utility Functions

import { assertValidTransition, getAllowedTransitions } from "@forjakit/scheduling";

getAllowedTransitions("SCHEDULED"); // ["IN_PROGRESS", "CANCELLED"]
getAllowedTransitions("FINISHED");  // []

assertValidTransition("SCHEDULED", "IN_PROGRESS"); // OK
assertValidTransition("FINISHED", "SCHEDULED");    // throws SchedulingError

Conflict Detection

When enabled (default), the service prevents scheduling overlapping events for the same participant(s).

How it works:

  1. On scheduleEvent or updateEvent (when time changes), the service computes the event's end time: scheduledAt + durationMinutes.
  2. It calls storage.findOverlappingEvents() with the tenant, participant IDs, start time, and end time.
  3. If any overlapping events are returned, it identifies which participants conflict and throws PARTICIPANT_CONFLICT.
  4. On updates, the event being updated is excluded from the overlap check via excludeEventId.

Only active events (not FINISHED or CANCELLED) are considered for conflicts.

Disabling conflict detection:

const service = createSchedulingService({
  storage,
  metadataSchema,
  conflictDetection: { enabled: false },
});

Utility Functions

import { computeEndTime, assertNoConflicts } from "@forjakit/scheduling";

const end = computeEndTime(new Date("2026-04-01T14:00:00Z"), 90);
// 2026-04-01T15:30:00.000Z

Availability Management

An opt-in feature for appointment-based scheduling (e.g., barbershops, clinics). Requires configuring availability in the service config.

Concept: Participants define recurring weekly availability windows (e.g., "Monday 09:00-18:00"). The service then computes open slots by subtracting existing events from those windows.

const service = createSchedulingService({
  storage,
  metadataSchema,
  availability: {
    storage: availabilityStorage, // implements AvailabilityStorage
    defaultSlotDuration: 30,      // minutes
  },
});

// Define when a professional is available
await service.setAvailability({
  participantId: "barber-1",
  participantType: "professional",
  tenantId: "tenant-1",
  dayOfWeek: 1, // Monday
  startTime: "09:00",
  endTime: "18:00",
});

// Get open 30-minute slots for a specific date
const slots = await service.getAvailableSlots("barber-1", "tenant-1", new Date("2026-04-07"));

AvailabilityWindow

| Field | Type | Description | | ----------------- | -------- | -------------------------------------------- | | participantId | string | The participant this window belongs to | | participantType | string | Consumer-defined type tag | | tenantId | string | Tenant isolation key | | dayOfWeek | number | ISO day of week: 0 = Sunday, 6 = Saturday | | startTime | string | "HH:MM" format | | endTime | string | "HH:MM" format |

TimeSlot

| Field | Type | Description | | --------------- | --------------- | ------------------------ | | startsAt | Date | Slot start time | | endsAt | Date | Slot end time | | participantId | string | The participant | | venueId | string \| null| Always null currently |

Error Handling

All business errors are thrown as SchedulingError instances with a machine-readable code and an HTTP-friendly statusCode.

import { SchedulingError } from "@forjakit/scheduling";

try {
  await service.scheduleEvent(input);
} catch (err) {
  if (err instanceof SchedulingError) {
    console.log(err.code);       // "PARTICIPANT_CONFLICT"
    console.log(err.statusCode); // 409
    console.log(err.message);    // "Participant(s) team-1 already have an event at ..."
  }
}

Error Codes

| Code | HTTP Status | Thrown When | | ------------------------ | ----------- | ------------------------------------------------ | | EVENT_NOT_FOUND | 404 | Event ID does not exist in the tenant | | EVENT_NOT_MODIFIABLE | 409 | Attempting to modify a FINISHED or CANCELLED event| | INVALID_TRANSITION | 409 | Status transition is not allowed | | PARTICIPANT_CONFLICT | 409 | Participant has an overlapping event | | FEATURE_NOT_CONFIGURED | 500 | Using availability without configuring it |

Error Factories

The Errors object provides factory functions for creating errors programmatically:

import { Errors } from "@forjakit/scheduling";

Errors.eventNotFound("evt-123");
Errors.eventNotModifiable("FINISHED");
Errors.invalidTransition("FINISHED", "SCHEDULED");
Errors.participantConflict(["team-1", "team-2"], new Date());
Errors.featureNotConfigured("Availability");

TypeScript Generics

The TMeta generic parameter flows through the entire system:

createSchedulingSchemas(metadataSchema: TMeta)
  └→ schemas.CreateEventInput includes metadata: TMeta

createSchedulingService<TMeta>({ metadataSchema, storage })
  └→ storage: SchedulingStorage<z.infer<TMeta>>
  └→ service.scheduleEvent() → Promise<ScheduledEvent<z.infer<TMeta>>>
  └→ service.getEvent()      → Promise<ScheduledEvent<z.infer<TMeta>>>
  └→ ...

This means your IDE provides full autocompletion for event.metadata.round, event.metadata.serviceType, etc., based on the schema you provide.

Storage Interface

To use the core package directly, implement the SchedulingStorage<TMeta> interface:

interface SchedulingStorage<TMeta> {
  createEvent(input: CreateEventInput<TMeta>): Promise<ScheduledEvent<TMeta>>;
  findEventById(id: string, tenantId: string): Promise<ScheduledEvent<TMeta> | null>;
  updateEvent(id: string, tenantId: string, input: UpdateEventInput<TMeta>): Promise<ScheduledEvent<TMeta>>;
  updateEventStatus(id: string, tenantId: string, status: EventStatus): Promise<ScheduledEvent<TMeta>>;
  deleteEvent(id: string, tenantId: string): Promise<void>;
  listEvents(filters: EventFilters): Promise<ScheduledEvent<TMeta>[]>;
  findOverlappingEvents(
    tenantId: string,
    participantIds: string[],
    startsAt: Date,
    endsAt: Date,
    excludeEventId?: string
  ): Promise<ScheduledEvent<TMeta>[]>;
}

For availability, implement AvailabilityStorage:

interface AvailabilityStorage {
  upsertAvailability(input: AvailabilityWindow): Promise<AvailabilityWindow>;
  findAvailability(participantId: string, tenantId: string): Promise<AvailabilityWindow[]>;
  deleteAvailability(participantId: string, tenantId: string, dayOfWeek: number): Promise<void>;
}

Or use @forjakit/scheduling-prisma for a ready-made Prisma implementation.

Full Working Example

import { createSchedulingService, type SchedulingStorage } from "@forjakit/scheduling";
import { z } from "zod";

// 1. Define your metadata schema
const matchMetadata = z.object({
  round: z.number().int().positive(),
  group: z.string().optional(),
});

type MatchMeta = z.infer<typeof matchMetadata>;

// 2. Implement storage (or use @forjakit/scheduling-prisma)
const storage: SchedulingStorage<MatchMeta> = {
  // ... your implementation
};

// 3. Create the service
const scheduling = createSchedulingService({
  storage,
  metadataSchema: matchMetadata,
});

// 4. Schedule a match
const match = await scheduling.scheduleEvent({
  tenantId: "org-1",
  contextId: "championship-2026",
  participants: [
    { id: "team-a", label: "Team Alpha", type: "team" },
    { id: "team-b", label: "Team Beta", type: "team" },
  ],
  scheduledAt: new Date("2026-04-01T14:00:00Z"),
  durationMinutes: 90,
  venue: { id: "court-1", label: "Main Court", type: "court" },
  metadata: { round: 1, group: "A" },
});

// 5. Transition status
await scheduling.transitionStatus(match.id, "org-1", "IN_PROGRESS");
await scheduling.transitionStatus(match.id, "org-1", "FINISHED");

// 6. List upcoming matches
const upcoming = await scheduling.listEvents({
  tenantId: "org-1",
  contextId: "championship-2026",
  status: "SCHEDULED",
  from: new Date(),
});

Exports

// Service
export { createSchedulingService } from "@forjakit/scheduling";
export type { SchedulingService, SchedulingServiceConfig } from "@forjakit/scheduling";

// Schemas
export { createSchedulingSchemas, EventStatusSchema } from "@forjakit/scheduling";
export type { SchedulingSchemas } from "@forjakit/scheduling";

// Transitions
export { assertValidTransition, getAllowedTransitions } from "@forjakit/scheduling";

// Conflicts
export { assertNoConflicts, computeEndTime } from "@forjakit/scheduling";

// Errors
export { SchedulingError, Errors } from "@forjakit/scheduling";

// Types
export type {
  EventStatus, Participant, Venue, ScheduledEvent,
  AvailabilityWindow, TimeSlot,
  CreateEventInput, UpdateEventInput, EventFilters,
  SchedulingStorage, AvailabilityStorage,
} from "@forjakit/scheduling";