obey-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Generates src/generated/api-types.ts from components.schemas in openapi.yaml.
|
|
3
|
+
//
|
|
4
|
+
// The parser only understands the small YAML subset that openapi.yaml uses: nested mappings,
|
|
5
|
+
// scalars and one-line [a, b] lists. Usage: node scripts/gen-types.mjs [--check]
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const specPath = resolve(root, "openapi.yaml");
|
|
13
|
+
const outPath = resolve(root, "src/generated/api-types.ts");
|
|
14
|
+
|
|
15
|
+
const HEADER = [
|
|
16
|
+
"// SPDX-License-Identifier: Apache-2.0",
|
|
17
|
+
"// Generated from openapi.yaml by scripts/gen-types.mjs — do not edit.",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function unquote(text) {
|
|
21
|
+
const match = /^(['"])(.*)\1$/.exec(text);
|
|
22
|
+
return match ? match[2] : text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseValue(text) {
|
|
26
|
+
if (text.startsWith("[") && text.endsWith("]")) {
|
|
27
|
+
const parts = text.slice(1, -1).split(",");
|
|
28
|
+
return parts.map((part) => unquote(part.trim())).filter((part) => part !== "");
|
|
29
|
+
}
|
|
30
|
+
return unquote(text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseYaml(text) {
|
|
34
|
+
const rootNode = {};
|
|
35
|
+
const stack = [{ indent: -1, node: rootNode }];
|
|
36
|
+
for (const raw of text.split("\n")) {
|
|
37
|
+
const line = raw.trim();
|
|
38
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
39
|
+
const indent = raw.length - raw.trimStart().length;
|
|
40
|
+
const match = /^([^:]+?):(?:\s+(.*))?$/.exec(line);
|
|
41
|
+
if (!match) throw new Error(`unsupported YAML line: ${raw}`);
|
|
42
|
+
while (stack[stack.length - 1].indent >= indent) stack.pop();
|
|
43
|
+
const parent = stack[stack.length - 1].node;
|
|
44
|
+
const key = unquote(match[1].trim());
|
|
45
|
+
if (match[2] === undefined) {
|
|
46
|
+
parent[key] = {};
|
|
47
|
+
stack.push({ indent, node: parent[key] });
|
|
48
|
+
} else {
|
|
49
|
+
parent[key] = parseValue(match[2].trim());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return rootNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const pascal = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
56
|
+
const upperSnake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
57
|
+
|
|
58
|
+
function tsType(schemaName, propName, prop, extras) {
|
|
59
|
+
if (prop.$ref) return prop.$ref.split("/").pop();
|
|
60
|
+
if (prop.enum) {
|
|
61
|
+
const typeName = `${schemaName}${pascal(propName)}`;
|
|
62
|
+
const constName = `${upperSnake(schemaName)}_${upperSnake(propName)}_VALUES`;
|
|
63
|
+
const values = prop.enum.map((value) => JSON.stringify(value)).join(", ");
|
|
64
|
+
extras.push(`export const ${constName} = [${values}] as const;`);
|
|
65
|
+
extras.push(`export type ${typeName} = (typeof ${constName})[number];`, "");
|
|
66
|
+
return typeName;
|
|
67
|
+
}
|
|
68
|
+
switch (prop.type) {
|
|
69
|
+
case "string":
|
|
70
|
+
return "string";
|
|
71
|
+
case "integer":
|
|
72
|
+
case "number":
|
|
73
|
+
return "number";
|
|
74
|
+
case "boolean":
|
|
75
|
+
return "boolean";
|
|
76
|
+
case "array":
|
|
77
|
+
return `${tsType(schemaName, propName, prop.items, extras)}[]`;
|
|
78
|
+
default:
|
|
79
|
+
throw new Error(`unsupported type for ${schemaName}.${propName}: ${prop.type}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function render(spec) {
|
|
84
|
+
const lines = [...HEADER, ""];
|
|
85
|
+
for (const [name, schema] of Object.entries(spec.components.schemas)) {
|
|
86
|
+
const required = schema.required ?? [];
|
|
87
|
+
const extras = [];
|
|
88
|
+
const fields = Object.entries(schema.properties).map(([propName, prop]) => {
|
|
89
|
+
const optional = required.includes(propName) ? "" : "?";
|
|
90
|
+
return ` ${propName}${optional}: ${tsType(name, propName, prop, extras)};`;
|
|
91
|
+
});
|
|
92
|
+
lines.push(...extras, `export interface ${name} {`, ...fields, "}", "");
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const output = render(parseYaml(readFileSync(specPath, "utf8")));
|
|
98
|
+
|
|
99
|
+
if (process.argv.includes("--check")) {
|
|
100
|
+
if (readFileSync(outPath, "utf8") !== output) {
|
|
101
|
+
console.error("src/generated/api-types.ts is out of date; run `pnpm gen:types`.");
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
} else {
|
|
104
|
+
console.log("src/generated/api-types.ts is up to date.");
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
writeFileSync(outPath, output);
|
|
108
|
+
console.log("wrote src/generated/api-types.ts");
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ApiClient, ApiError } from "./client.js";
|
|
4
|
+
|
|
5
|
+
interface RecordedCall {
|
|
6
|
+
url: string;
|
|
7
|
+
init: RequestInit | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stubFetch(body: unknown, status = 200): { fetchFn: typeof fetch; calls: RecordedCall[] } {
|
|
11
|
+
const calls: RecordedCall[] = [];
|
|
12
|
+
const fetchFn: typeof fetch = async (input, init) => {
|
|
13
|
+
calls.push({ url: String(input), init });
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
return { fetchFn, calls };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const shiftJson = { id: "s1", personId: "p1", start: "2026-03-04T09:00:00Z", end: "2026-03-04T17:00:00Z" };
|
|
23
|
+
|
|
24
|
+
describe("ApiClient", () => {
|
|
25
|
+
it("lists people from the items envelope", async () => {
|
|
26
|
+
const { fetchFn, calls } = stubFetch({ items: [{ id: "p1", name: "Ada Example", role: "barista" }] });
|
|
27
|
+
const client = new ApiClient({ baseUrl: "https://team.example.com/api/", fetchFn });
|
|
28
|
+
await expect(client.listPeople()).resolves.toEqual([{ id: "p1", name: "Ada Example", role: "barista" }]);
|
|
29
|
+
expect(calls[0]?.url).toBe("https://team.example.com/api/people");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("sends the week range as a query and posts new shifts as JSON", async () => {
|
|
33
|
+
const { fetchFn, calls } = stubFetch(shiftJson);
|
|
34
|
+
const client = new ApiClient({ baseUrl: "/api", fetchFn });
|
|
35
|
+
const from = new Date("2026-03-02T00:00:00Z");
|
|
36
|
+
await client.createShift({ personId: "p1", start: shiftJson.start, end: shiftJson.end });
|
|
37
|
+
expect(calls[0]?.init?.method).toBe("POST");
|
|
38
|
+
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
|
|
39
|
+
personId: "p1",
|
|
40
|
+
start: shiftJson.start,
|
|
41
|
+
end: shiftJson.end,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const list = stubFetch({ items: [shiftJson] });
|
|
45
|
+
const listing = new ApiClient({ baseUrl: "/api", fetchFn: list.fetchFn });
|
|
46
|
+
await listing.listShifts({ from, to: new Date("2026-03-09T00:00:00Z") });
|
|
47
|
+
expect(list.calls[0]?.url).toBe("/api/shifts?from=2026-03-02T00%3A00%3A00.000Z&to=2026-03-09T00%3A00%3A00.000Z");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("raises ApiError for HTTP failures and for bodies of the wrong shape", async () => {
|
|
51
|
+
const failing = new ApiClient({ baseUrl: "/api", fetchFn: stubFetch({ error: "boom" }, 503).fetchFn });
|
|
52
|
+
await expect(failing.listPeople()).rejects.toMatchObject({ name: "ApiError", status: 503 });
|
|
53
|
+
|
|
54
|
+
const malformed = new ApiClient({ baseUrl: "/api", fetchFn: stubFetch({ items: [{ id: 7 }] }).fetchFn });
|
|
55
|
+
await expect(malformed.listPeople()).rejects.toBeInstanceOf(ApiError);
|
|
56
|
+
|
|
57
|
+
const noEnvelope = new ApiClient({ baseUrl: "/api", fetchFn: stubFetch([]).fetchFn });
|
|
58
|
+
await expect(noEnvelope.listShifts({ from: new Date(0), to: new Date(1) })).rejects.toThrow(/no items array/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type { NewShift, Person, Shift } from "../generated/api-types.js";
|
|
3
|
+
import { isPerson, isRecord, isShift } from "./response-guards.js";
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
readonly status: number | undefined;
|
|
7
|
+
|
|
8
|
+
constructor(message: string, status?: number) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ApiError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ApiClientOptions {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
fetchFn?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DateRange {
|
|
21
|
+
from: Date;
|
|
22
|
+
to: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApiClient {
|
|
26
|
+
private readonly baseUrl: string;
|
|
27
|
+
private readonly fetchFn: typeof fetch;
|
|
28
|
+
|
|
29
|
+
constructor(options: ApiClientOptions) {
|
|
30
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
31
|
+
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async listPeople(): Promise<Person[]> {
|
|
35
|
+
return parseItems(await this.request("/people"), isPerson, "people");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async listShifts(range: DateRange): Promise<Shift[]> {
|
|
39
|
+
const query = new URLSearchParams({ from: range.from.toISOString(), to: range.to.toISOString() });
|
|
40
|
+
return parseItems(await this.request(`/shifts?${query.toString()}`), isShift, "shifts");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async createShift(input: NewShift): Promise<Shift> {
|
|
44
|
+
const body = await this.request("/shifts", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
body: JSON.stringify(input),
|
|
48
|
+
});
|
|
49
|
+
if (!isShift(body)) throw new ApiError("the server returned an unexpected shape for the new shift");
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async request(path: string, init?: RequestInit): Promise<unknown> {
|
|
54
|
+
const response = await this.fetchFn(`${this.baseUrl}${path}`, init);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new ApiError(`request to ${path} failed with status ${response.status}`, response.status);
|
|
57
|
+
}
|
|
58
|
+
const body: unknown = await response.json();
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseItems<T>(body: unknown, guard: (value: unknown) => value is T, what: string): T[] {
|
|
64
|
+
if (!isRecord(body) || !Array.isArray(body["items"])) {
|
|
65
|
+
throw new ApiError(`the ${what} response has no items array`);
|
|
66
|
+
}
|
|
67
|
+
const items: unknown[] = body["items"];
|
|
68
|
+
const badIndex = items.findIndex((item) => !guard(item));
|
|
69
|
+
if (badIndex !== -1) throw new ApiError(`${what}[${badIndex}] has an unexpected shape`);
|
|
70
|
+
return items.filter(guard);
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { isPerson, isPersonRole, isRecord, isShift } from "./response-guards.js";
|
|
4
|
+
|
|
5
|
+
describe("response guards", () => {
|
|
6
|
+
it("accept well-formed values and reject everything else", () => {
|
|
7
|
+
expect(isRecord({})).toBe(true);
|
|
8
|
+
expect(isRecord([])).toBe(false);
|
|
9
|
+
expect(isRecord(null)).toBe(false);
|
|
10
|
+
|
|
11
|
+
expect(isPersonRole("baker")).toBe(true);
|
|
12
|
+
expect(isPersonRole("pilot")).toBe(false);
|
|
13
|
+
|
|
14
|
+
expect(isPerson({ id: "p1", name: "Ada", role: "barista", maxWeeklyHours: 20 })).toBe(true);
|
|
15
|
+
expect(isPerson({ id: "p1", name: "Ada", role: "pilot" })).toBe(false);
|
|
16
|
+
expect(isPerson({ id: "p1", name: "Ada", role: "barista", maxWeeklyHours: "20" })).toBe(false);
|
|
17
|
+
|
|
18
|
+
expect(isShift({ id: "s", personId: "p", start: "a", end: "b", location: "Bar" })).toBe(true);
|
|
19
|
+
expect(isShift({ id: "s", personId: "p", start: "a" })).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The team API is not under our control and returns plain JSON, so every response is checked
|
|
3
|
+
// by hand before it is treated as one of the generated types.
|
|
4
|
+
import { PERSON_ROLE_VALUES } from "../generated/api-types.js";
|
|
5
|
+
import type { Person, PersonRole, Shift } from "../generated/api-types.js";
|
|
6
|
+
|
|
7
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isOptional(value: unknown, type: "string" | "number"): boolean {
|
|
12
|
+
return value === undefined || typeof value === type;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPersonRole(value: unknown): value is PersonRole {
|
|
16
|
+
return PERSON_ROLE_VALUES.some((role) => role === value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isPerson(value: unknown): value is Person {
|
|
20
|
+
return (
|
|
21
|
+
isRecord(value) &&
|
|
22
|
+
typeof value["id"] === "string" &&
|
|
23
|
+
typeof value["name"] === "string" &&
|
|
24
|
+
isPersonRole(value["role"]) &&
|
|
25
|
+
isOptional(value["maxWeeklyHours"], "number")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isShift(value: unknown): value is Shift {
|
|
30
|
+
return (
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
typeof value["id"] === "string" &&
|
|
33
|
+
typeof value["personId"] === "string" &&
|
|
34
|
+
typeof value["start"] === "string" &&
|
|
35
|
+
typeof value["end"] === "string" &&
|
|
36
|
+
isOptional(value["location"], "string")
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
addDays,
|
|
5
|
+
formatDayLabel,
|
|
6
|
+
formatTime,
|
|
7
|
+
hoursBetween,
|
|
8
|
+
parseIsoDateTime,
|
|
9
|
+
rangesOverlap,
|
|
10
|
+
startOfWeek,
|
|
11
|
+
toDayKey,
|
|
12
|
+
} from "./date-utils.js";
|
|
13
|
+
|
|
14
|
+
describe("parseIsoDateTime", () => {
|
|
15
|
+
it("accepts date-times with an explicit offset", () => {
|
|
16
|
+
expect(parseIsoDateTime("2026-03-04T09:00:00Z").toISOString()).toBe("2026-03-04T09:00:00.000Z");
|
|
17
|
+
expect(parseIsoDateTime("2026-03-04T10:00:00+01:00").toISOString()).toBe("2026-03-04T09:00:00.000Z");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects date-only, zone-less and garbage input", () => {
|
|
21
|
+
for (const bad of ["2026-03-04", "2026-03-04T09:00:00", "not a date", "2026-13-40T00:00:00Z"]) {
|
|
22
|
+
expect(() => parseIsoDateTime(bad)).toThrow(RangeError);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("week helpers", () => {
|
|
28
|
+
it("finds the Monday of the week, including for a Sunday", () => {
|
|
29
|
+
expect(toDayKey(startOfWeek(new Date("2026-03-04T15:30:00Z")))).toBe("2026-03-02");
|
|
30
|
+
expect(toDayKey(startOfWeek(new Date("2026-03-08T23:59:00Z")))).toBe("2026-03-02");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("adds days across a month boundary", () => {
|
|
34
|
+
expect(toDayKey(addDays(new Date("2026-02-27T00:00:00Z"), 3))).toBe("2026-03-02");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("time helpers", () => {
|
|
39
|
+
it("measures hours and formats times in UTC", () => {
|
|
40
|
+
const start = new Date("2026-03-04T09:05:00Z");
|
|
41
|
+
expect(hoursBetween(start, new Date("2026-03-04T17:35:00Z"))).toBe(8.5);
|
|
42
|
+
expect(formatTime(start)).toBe("09:05");
|
|
43
|
+
expect(formatDayLabel(start)).toBe("Wed 4 Mar");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("treats touching ranges as not overlapping", () => {
|
|
47
|
+
const at = (hour: number): Date => new Date(Date.UTC(2026, 2, 4, hour));
|
|
48
|
+
expect(rangesOverlap(at(9), at(12), at(12), at(15))).toBe(false);
|
|
49
|
+
expect(rangesOverlap(at(9), at(13), at(12), at(15))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// All scheduling maths is done in UTC so results do not depend on the viewer's time zone.
|
|
3
|
+
|
|
4
|
+
const MS_PER_HOUR = 3_600_000;
|
|
5
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
6
|
+
|
|
7
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
|
8
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
9
|
+
|
|
10
|
+
// A full ISO 8601 date-time with an explicit offset; date-only and zone-less values are rejected.
|
|
11
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
12
|
+
|
|
13
|
+
/** Parse an ISO 8601 date-time that carries a time zone, or throw a RangeError. */
|
|
14
|
+
export function parseIsoDateTime(value: string): Date {
|
|
15
|
+
const parsed = new Date(value);
|
|
16
|
+
if (!ISO_DATE_TIME.test(value) || Number.isNaN(parsed.getTime())) {
|
|
17
|
+
throw new RangeError(`invalid ISO date-time: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Midnight UTC on the Monday of the week that contains `date`. */
|
|
23
|
+
export function startOfWeek(date: Date): Date {
|
|
24
|
+
const daysSinceMonday = (date.getUTCDay() + 6) % 7;
|
|
25
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - daysSinceMonday));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addDays(date: Date, days: number): Date {
|
|
29
|
+
return new Date(date.getTime() + days * MS_PER_DAY);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The seven days of the week that starts at `weekStart`. */
|
|
33
|
+
export function weekDays(weekStart: Date): Date[] {
|
|
34
|
+
return Array.from({ length: 7 }, (_, offset) => addDays(weekStart, offset));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `YYYY-MM-DD` in UTC, used as a map key for a calendar day. */
|
|
38
|
+
export function toDayKey(date: Date): string {
|
|
39
|
+
return date.toISOString().slice(0, 10);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hoursBetween(start: Date, end: Date): number {
|
|
43
|
+
return (end.getTime() - start.getTime()) / MS_PER_HOUR;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when the half-open ranges [aStart, aEnd) and [bStart, bEnd) share any time. */
|
|
47
|
+
export function rangesOverlap(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
|
48
|
+
return aStart < bEnd && bStart < aEnd;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `HH:MM` in UTC. */
|
|
52
|
+
export function formatTime(date: Date): string {
|
|
53
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
54
|
+
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A short column label such as `Mon 3 Mar`. */
|
|
58
|
+
export function formatDayLabel(date: Date): string {
|
|
59
|
+
return `${WEEKDAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Generated from openapi.yaml by scripts/gen-types.mjs — do not edit.
|
|
3
|
+
|
|
4
|
+
export const PERSON_ROLE_VALUES = ["barista", "baker", "cashier", "manager"] as const;
|
|
5
|
+
export type PersonRole = (typeof PERSON_ROLE_VALUES)[number];
|
|
6
|
+
|
|
7
|
+
export interface Person {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: PersonRole;
|
|
11
|
+
maxWeeklyHours?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Shift {
|
|
15
|
+
id: string;
|
|
16
|
+
personId: string;
|
|
17
|
+
start: string;
|
|
18
|
+
end: string;
|
|
19
|
+
location?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NewShift {
|
|
23
|
+
personId: string;
|
|
24
|
+
start: string;
|
|
25
|
+
end: string;
|
|
26
|
+
location?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PersonList {
|
|
30
|
+
items: Person[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShiftList {
|
|
34
|
+
items: Shift[];
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
import type { LogSink } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
describe("createLogger", () => {
|
|
7
|
+
it("prefixes messages with the scope and drops levels below the minimum", () => {
|
|
8
|
+
const seen: string[] = [];
|
|
9
|
+
const record =
|
|
10
|
+
(level: string) =>
|
|
11
|
+
(...args: unknown[]): void => {
|
|
12
|
+
seen.push(`${level}:${String(args[0])}`);
|
|
13
|
+
};
|
|
14
|
+
const sink: LogSink = { debug: record("debug"), info: record("info"), warn: record("warn"), error: record("error") };
|
|
15
|
+
|
|
16
|
+
const logger = createLogger("planner", { level: "warn", sink });
|
|
17
|
+
logger.debug("hidden");
|
|
18
|
+
logger.info("hidden too");
|
|
19
|
+
logger.warn("two shifts overlap");
|
|
20
|
+
logger.error("failed");
|
|
21
|
+
|
|
22
|
+
expect(seen).toEqual(["warn:[planner] two shifts overlap", "error:[planner] failed"]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
3
|
+
|
|
4
|
+
export interface LogSink {
|
|
5
|
+
debug(...args: unknown[]): void;
|
|
6
|
+
info(...args: unknown[]): void;
|
|
7
|
+
warn(...args: unknown[]): void;
|
|
8
|
+
error(...args: unknown[]): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Logger {
|
|
12
|
+
debug(message: string, ...context: unknown[]): void;
|
|
13
|
+
info(message: string, ...context: unknown[]): void;
|
|
14
|
+
warn(message: string, ...context: unknown[]): void;
|
|
15
|
+
error(message: string, ...context: unknown[]): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LoggerOptions {
|
|
19
|
+
level?: LogLevel;
|
|
20
|
+
sink?: LogSink;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
24
|
+
|
|
25
|
+
/** Create a scoped logger; this is the only place in the app that talks to the console. */
|
|
26
|
+
export function createLogger(scope: string, options: LoggerOptions = {}): Logger {
|
|
27
|
+
const minimum = LEVEL_ORDER[options.level ?? "info"];
|
|
28
|
+
const sink = options.sink ?? console;
|
|
29
|
+
const emit =
|
|
30
|
+
(level: LogLevel) =>
|
|
31
|
+
(message: string, ...context: unknown[]): void => {
|
|
32
|
+
if (LEVEL_ORDER[level] < minimum) return;
|
|
33
|
+
sink[level](`[${scope}] ${message}`, ...context);
|
|
34
|
+
};
|
|
35
|
+
return { debug: emit("debug"), info: emit("info"), warn: emit("warn"), error: emit("error") };
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { ApiClient } from "./api/client.js";
|
|
3
|
+
import { addDays, startOfWeek } from "./date-utils.js";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import { renderWeekTable } from "./render-table.js";
|
|
6
|
+
|
|
7
|
+
const logger = createLogger("main");
|
|
8
|
+
|
|
9
|
+
async function main(): Promise<void> {
|
|
10
|
+
const root = document.querySelector<HTMLElement>("#app");
|
|
11
|
+
if (!root) {
|
|
12
|
+
logger.error("missing #app element");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const client = new ApiClient({ baseUrl: root.dataset["apiBase"] ?? "/api" });
|
|
16
|
+
const weekStart = startOfWeek(new Date());
|
|
17
|
+
try {
|
|
18
|
+
const [people, shifts] = await Promise.all([
|
|
19
|
+
client.listPeople(),
|
|
20
|
+
client.listShifts({ from: weekStart, to: addDays(weekStart, 7) }),
|
|
21
|
+
]);
|
|
22
|
+
root.innerHTML = renderWeekTable(people, shifts, weekStart);
|
|
23
|
+
logger.info("rendered the week", { people: people.length, shifts: shifts.length });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logger.error("could not load the schedule", error);
|
|
26
|
+
root.textContent = "The schedule could not be loaded. Please try again later.";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
void main();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Person, Shift } from "./generated/api-types.js";
|
|
4
|
+
import { escapeHtml, renderWeekTable } from "./render-table.js";
|
|
5
|
+
|
|
6
|
+
const WEEK = new Date("2026-03-02T00:00:00Z");
|
|
7
|
+
|
|
8
|
+
describe("escapeHtml", () => {
|
|
9
|
+
it("escapes markup characters", () => {
|
|
10
|
+
expect(escapeHtml(`<b onclick="x">Tom & 'Jerry'</b>`)).toBe(
|
|
11
|
+
"<b onclick="x">Tom & 'Jerry'</b>",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("renderWeekTable", () => {
|
|
17
|
+
const people: Person[] = [
|
|
18
|
+
{ id: "p1", name: "Ada <Example>", role: "barista" },
|
|
19
|
+
{ id: "p2", name: "Bo Example", role: "baker" },
|
|
20
|
+
];
|
|
21
|
+
const shifts: Shift[] = [
|
|
22
|
+
{ id: "s1", personId: "p1", start: "2026-03-04T09:00:00Z", end: "2026-03-04T17:00:00Z", location: "Front & bar" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
it("renders one row per person with escaped names, times and total hours", () => {
|
|
26
|
+
const html = renderWeekTable(people, shifts, WEEK);
|
|
27
|
+
expect(html.match(/<tr>/g)).toHaveLength(3);
|
|
28
|
+
expect(html).toContain("Ada <Example>");
|
|
29
|
+
expect(html).toContain("Wed 4 Mar");
|
|
30
|
+
expect(html).toContain('<span class="shift">09:00-17:00 Front & bar</span>');
|
|
31
|
+
expect(html).toContain('<td class="total">8h</td>');
|
|
32
|
+
expect(html).toContain('<td class="total">0h</td>');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { formatDayLabel, formatTime, parseIsoDateTime, toDayKey, weekDays } from "./date-utils.js";
|
|
3
|
+
import type { Person, Shift } from "./generated/api-types.js";
|
|
4
|
+
import { groupByDay, weeklyHours } from "./shift-planner.js";
|
|
5
|
+
|
|
6
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
7
|
+
"&": "&",
|
|
8
|
+
"<": "<",
|
|
9
|
+
">": ">",
|
|
10
|
+
'"': """,
|
|
11
|
+
"'": "'",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Escape text for use inside HTML element content or a quoted attribute. */
|
|
15
|
+
export function escapeHtml(text: string): string {
|
|
16
|
+
return text.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderCell(shifts: readonly Shift[]): string {
|
|
20
|
+
const items = shifts.map((shift) => {
|
|
21
|
+
const range = `${formatTime(parseIsoDateTime(shift.start))}-${formatTime(parseIsoDateTime(shift.end))}`;
|
|
22
|
+
const place = shift.location ? ` ${escapeHtml(shift.location)}` : "";
|
|
23
|
+
return `<span class="shift">${range}${place}</span>`;
|
|
24
|
+
});
|
|
25
|
+
return `<td>${items.join("<br>")}</td>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render the weekly rota as an HTML table: one row per person, one column per day. */
|
|
29
|
+
export function renderWeekTable(people: readonly Person[], shifts: readonly Shift[], weekStart: Date): string {
|
|
30
|
+
const days = weekDays(weekStart);
|
|
31
|
+
const hours = weeklyHours(shifts, weekStart);
|
|
32
|
+
const header = days.map((day) => `<th scope="col">${escapeHtml(formatDayLabel(day))}</th>`).join("");
|
|
33
|
+
const rows = people.map((person) => {
|
|
34
|
+
const own = groupByDay(
|
|
35
|
+
shifts.filter((shift) => shift.personId === person.id),
|
|
36
|
+
weekStart,
|
|
37
|
+
);
|
|
38
|
+
const cells = days.map((day) => renderCell(own.get(toDayKey(day)) ?? [])).join("");
|
|
39
|
+
const total = hours.get(person.id) ?? 0;
|
|
40
|
+
return `<tr><th scope="row">${escapeHtml(person.name)}</th>${cells}<td class="total">${total}h</td></tr>`;
|
|
41
|
+
});
|
|
42
|
+
return [
|
|
43
|
+
'<table class="week">',
|
|
44
|
+
`<thead><tr><th scope="col">Team</th>${header}<th scope="col">Hours</th></tr></thead>`,
|
|
45
|
+
`<tbody>${rows.join("")}</tbody>`,
|
|
46
|
+
"</table>",
|
|
47
|
+
].join("");
|
|
48
|
+
}
|