Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 150 additions & 23 deletions apps/server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,14 @@
"ledgerId": {
"type": "string"
},
"timezone": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"]
"required": ["id", "ledgerId", "createdAt", "updatedAt"]
},
"Allocation": {
"type": "object",
Expand Down Expand Up @@ -125,6 +122,50 @@
},
"required": ["id", "ledgerId", "url", "createdAt", "updatedAt"]
},
"AvailabilityItem": {
"type": "object",
"properties": {
"resourceId": {
"type": "string"
},
"timeline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startAt": {
"type": "string"
},
"endAt": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["free", "busy"]
}
},
"required": ["startAt", "endAt", "status"]
}
}
},
"required": ["resourceId", "timeline"]
},
"TimelineBlock": {
"type": "object",
"properties": {
"startAt": {
"type": "string"
},
"endAt": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["free", "busy"]
}
},
"required": ["startAt", "endAt", "status"]
},
"Error": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -358,17 +399,14 @@
"ledgerId": {
"type": "string"
},
"timezone": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"]
"required": ["id", "ledgerId", "createdAt", "updatedAt"]
}
}
},
Expand Down Expand Up @@ -397,13 +435,7 @@
"application/json": {
"schema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"default": "UTC",
"example": "America/New_York"
}
}
"properties": {}
}
}
}
Expand All @@ -425,17 +457,14 @@
"ledgerId": {
"type": "string"
},
"timezone": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"]
"required": ["id", "ledgerId", "createdAt", "updatedAt"]
}
},
"required": ["data"]
Expand Down Expand Up @@ -485,17 +514,14 @@
"ledgerId": {
"type": "string"
},
"timezone": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"]
"required": ["id", "ledgerId", "createdAt", "updatedAt"]
}
},
"required": ["data"]
Expand Down Expand Up @@ -605,6 +631,107 @@
}
}
},
"/v1/ledgers/{ledgerId}/availability": {
"get": {
"tags": ["Availability"],
"summary": "Query resource availability",
"description": "Returns a timeline of free/busy blocks for the specified resources within the given time window. Overlapping and adjacent allocations are merged into single busy blocks.",
"parameters": [
{
"schema": {
"type": "string"
},
"required": true,
"name": "ledgerId",
"in": "path"
},
{
"schema": {
"type": "array",
"items": {
"type": "string"
},
"description": "Resource IDs to query (can be repeated)",
"example": ["rsc_01abc123def456ghi789jkl012"]
},
"required": true,
"description": "Resource IDs to query (can be repeated)",
"name": "resourceIds",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date-time",
"description": "Start of the time window (ISO 8601)",
"example": "2026-01-04T10:00:00Z"
},
"required": true,
"description": "Start of the time window (ISO 8601)",
"name": "startAt",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date-time",
"description": "End of the time window (ISO 8601)",
"example": "2026-01-04T18:00:00Z"
},
"required": true,
"description": "End of the time window (ISO 8601)",
"name": "endAt",
"in": "query"
}
],
"responses": {
"200": {
"description": "Availability timeline for each resource",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"resourceId": {
"type": "string"
},
"timeline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startAt": {
"type": "string"
},
"endAt": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["free", "busy"]
}
},
"required": ["startAt", "endAt", "status"]
}
}
},
"required": ["resourceId", "timeline"]
}
}
},
"required": ["data"]
}
}
}
}
}
}
},
"/v1/ledgers/{ledgerId}/allocations": {
"get": {
"tags": ["Allocations"],
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface LedgersTable {
export interface ResourcesTable {
id: string;
ledgerId: string;
timezone: string;
createdAt: Generated<Date>;
updatedAt: Generated<Date>;
}
Expand Down
88 changes: 88 additions & 0 deletions apps/server/src/lib/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { TimelineBlock } from "@floyd-run/schema/types";

export interface Interval {
start: Date;
end: Date;
}

/**
* Clamps an interval to the query window [windowStart, windowEnd)
*/
export function clampInterval(interval: Interval, windowStart: Date, windowEnd: Date): Interval {
return {
start: interval.start < windowStart ? windowStart : interval.start,
end: interval.end > windowEnd ? windowEnd : interval.end,
};
}

/**
* Merges overlapping or adjacent intervals.
* Input must be sorted by start time.
*/
export function mergeIntervals(intervals: Interval[]): Interval[] {
if (intervals.length === 0) return [];

const merged: Interval[] = [];
let current = { ...intervals[0]! };

for (let i = 1; i < intervals.length; i++) {
const next = intervals[i]!;
if (next.start <= current.end) {
// Overlapping or adjacent - extend current
if (next.end > current.end) {
current.end = next.end;
}
} else {
// Gap - push current and start new
merged.push(current);
current = { ...next };
}
}
merged.push(current);

return merged;
}

/**
* Builds a timeline of free/busy blocks from merged busy intervals.
* Guarantees: sequential, non-overlapping, covers full window, no adjacent same-status.
*/
export function buildTimeline(
busyIntervals: Interval[],
windowStart: Date,
windowEnd: Date,
): TimelineBlock[] {
const timeline: TimelineBlock[] = [];
let cursor = windowStart;

for (const busy of busyIntervals) {
// Add free block before this busy interval (if gap exists)
if (busy.start > cursor) {
timeline.push({
startAt: cursor.toISOString(),
endAt: busy.start.toISOString(),
status: "free",
});
}

// Add busy block
timeline.push({
startAt: busy.start.toISOString(),
endAt: busy.end.toISOString(),
status: "busy",
});

cursor = busy.end;
}

// Add trailing free block if window extends past last busy
if (cursor < windowEnd) {
timeline.push({
startAt: cursor.toISOString(),
endAt: windowEnd.toISOString(),
status: "free",
});
}

return timeline;
}
13 changes: 13 additions & 0 deletions apps/server/src/migrations/20260206235234_remove-timezone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Database } from "database/schema";
import { Kysely } from "kysely";

export async function up(db: Kysely<Database>): Promise<void> {
await db.schema.alterTable("resources").dropColumn("timezone").execute();
}

export async function down(db: Kysely<Database>): Promise<void> {
await db.schema
.alterTable("resources")
.addColumn("timezone", "varchar(100)", (col) => col.notNull().defaultTo("UTC"))
.execute();
}
19 changes: 19 additions & 0 deletions apps/server/src/routes/v1/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Hono } from "hono";
import { services } from "../../services/index.js";

// Nested under /v1/ledgers/:ledgerId/availability
export const availability = new Hono().get("/", async (c) => {
const ledgerId = c.req.param("ledgerId")!;
const resourceIds = c.req.queries("resourceIds") || [];
const startAt = c.req.query("startAt")!;
const endAt = c.req.query("endAt")!;

const result = await services.availability.query({
ledgerId,
resourceIds,
startAt,
endAt,
});

return c.json({ data: result.items });
});
2 changes: 2 additions & 0 deletions apps/server/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { allocations } from "./allocations";
import { availability } from "./availability";
import { resources } from "./resources";
import { ledgers } from "./ledgers";
import { webhooks } from "./webhooks";
Expand All @@ -8,4 +9,5 @@ export const v1 = new Hono()
.route("/ledgers", ledgers)
.route("/ledgers/:ledgerId/resources", resources)
.route("/ledgers/:ledgerId/allocations", allocations)
.route("/ledgers/:ledgerId/availability", availability)
.route("/ledgers/:ledgerId/webhooks", webhooks);
Loading