Build Cobble project structure from the handoff
- Remove the preset document suite (letters, emails, invoices, quotes, PDF generation, line items) with its routes, API handlers and tables - Rebrand to Cobble: site data, Lato, handoff design tokens (light and dark) in main.css, brand assets in public/brand, ember stone logo and regenerated favicons - Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots) - Add the chat shell at /admin/bots: rail with pinned coordinator and user popover, bottom-anchored conversation with composer, working-on panel, and a send-message API scoped to the session user - Add BotStone and Wordmark components, crew status and relative time utils with tests, and update AGENTS.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { crewStatusLine, crewStatusSentence } from "./crew-status";
|
||||
|
||||
const crew = [
|
||||
{ kind: "coordinator", face: "working" },
|
||||
{ kind: "worker", face: "needs-you" },
|
||||
{ kind: "worker", face: "working" },
|
||||
{ kind: "worker", face: "working" },
|
||||
{ kind: "worker", face: "working" },
|
||||
{ kind: "worker", face: "asleep" },
|
||||
];
|
||||
|
||||
describe("crewStatusLine", () => {
|
||||
test("counts workers only", () => {
|
||||
expect(crewStatusLine({ bots: crew })).toBe("3 working · 1 needs you");
|
||||
});
|
||||
|
||||
test("pluralises need", () => {
|
||||
expect(
|
||||
crewStatusLine({
|
||||
bots: [
|
||||
{ kind: "worker", face: "needs-you" },
|
||||
{ kind: "worker", face: "needs-you" },
|
||||
],
|
||||
}),
|
||||
).toBe("2 need you");
|
||||
});
|
||||
|
||||
test("quiet crew", () => {
|
||||
expect(crewStatusLine({ bots: [{ kind: "worker", face: "done" }] })).toBe(
|
||||
"All quiet",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crewStatusSentence", () => {
|
||||
test("the signature sentence", () => {
|
||||
expect(crewStatusSentence({ bots: crew })).toBe(
|
||||
"Three bots are working. One needs you.",
|
||||
);
|
||||
});
|
||||
|
||||
test("single working bot", () => {
|
||||
expect(
|
||||
crewStatusSentence({ bots: [{ kind: "worker", face: "working" }] }),
|
||||
).toBe("One bot is working.");
|
||||
});
|
||||
|
||||
test("needs you without working bots names the bots", () => {
|
||||
expect(
|
||||
crewStatusSentence({
|
||||
bots: [
|
||||
{ kind: "worker", face: "needs-you" },
|
||||
{ kind: "worker", face: "needs-you" },
|
||||
],
|
||||
}),
|
||||
).toBe("Two bots need you.");
|
||||
});
|
||||
|
||||
test("nothing to report", () => {
|
||||
expect(crewStatusSentence({ bots: [] })).toBe("Nothing needs you.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BotFace, BotKind } from "@/src/dict/bot-dict";
|
||||
|
||||
type CrewBot = {
|
||||
kind?: BotKind | string | null;
|
||||
face?: BotFace | string | null;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bots: CrewBot[];
|
||||
};
|
||||
|
||||
const NUMBER_WORDS = [
|
||||
"No",
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
"Four",
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
"Eight",
|
||||
"Nine",
|
||||
"Ten",
|
||||
] as const;
|
||||
|
||||
function countWord({ n }: { n: number }): string {
|
||||
return NUMBER_WORDS[n] ?? String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Working and needs-you counts across the workers (the coordinator excluded).
|
||||
*/
|
||||
export function crewCounts({ bots }: Params) {
|
||||
const workers = bots.filter((bot) => bot.kind !== "coordinator");
|
||||
|
||||
return {
|
||||
working: workers.filter((bot) => bot.face === "working").length,
|
||||
needs_you: workers.filter((bot) => bot.face === "needs-you").length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The coordinator's rail subtitle: `3 working · 1 needs you`.
|
||||
*/
|
||||
export function crewStatusLine({ bots }: Params): string {
|
||||
const { working, needs_you } = crewCounts({ bots });
|
||||
const parts: string[] = [];
|
||||
|
||||
if (working) parts.push(`${working} working`);
|
||||
if (needs_you) parts.push(`${needs_you} need${needs_you == 1 ? "s" : ""} you`);
|
||||
|
||||
return parts.length ? parts.join(" · ") : "All quiet";
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature status sentence: `Three bots are working. One needs you.`
|
||||
*/
|
||||
export function crewStatusSentence({ bots }: Params): string {
|
||||
const { working, needs_you } = crewCounts({ bots });
|
||||
const sentences: string[] = [];
|
||||
|
||||
if (working) {
|
||||
sentences.push(
|
||||
working == 1
|
||||
? `One bot is working.`
|
||||
: `${countWord({ n: working })} bots are working.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (needs_you) {
|
||||
const subject = working
|
||||
? countWord({ n: needs_you })
|
||||
: `${countWord({ n: needs_you })} bot${needs_you == 1 ? "" : "s"}`;
|
||||
sentences.push(`${subject} need${needs_you == 1 ? "s" : ""} you.`);
|
||||
}
|
||||
|
||||
return sentences.length ? sentences.join(" ") : "Nothing needs you.";
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from "path";
|
||||
import type { User } from "../types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user?: BUN_SQLITE_BSQLITE_USERS | User;
|
||||
user?: BUN_SQLITE_COBBLE_USERS | User;
|
||||
};
|
||||
|
||||
export default function grabDirNames(params?: Params) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hexToRgbFractions from "./hex-to-rgb-fractions";
|
||||
|
||||
describe("hexToRgbFractions", () => {
|
||||
test("converts 6-digit hex", () => {
|
||||
expect(hexToRgbFractions({ hex: "#ff0000" })).toEqual({
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("expands 3-digit hex", () => {
|
||||
expect(hexToRgbFractions({ hex: "#fff" })).toEqual({
|
||||
r: 1,
|
||||
g: 1,
|
||||
b: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores alpha", () => {
|
||||
expect(hexToRgbFractions({ hex: "#00000080" })).toEqual({
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Converts `#rgb` / `#rrggbb` hex into `0-1` channel fractions (pdf-lib `rgb()`
|
||||
* input). Alpha in `#rrggbbaa` is ignored.
|
||||
*/
|
||||
export default function hexToRgbFractions({ hex }: { hex: string }): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
} {
|
||||
let clean = hex.replace("#", "");
|
||||
|
||||
if (clean.length == 3 || clean.length == 4) {
|
||||
clean = clean
|
||||
.slice(0, 3)
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const channel = (offset: number) =>
|
||||
(parseInt(clean.slice(offset, offset + 2), 16) || 0) / 255;
|
||||
|
||||
return { r: channel(0), g: channel(2), b: channel(4) };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import userTypeAssignment from "./user-type-assignment";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
target_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
|
||||
type Params = {
|
||||
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
actor_user_id?: number | string;
|
||||
target_user_id?: number | string;
|
||||
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
target_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
/**
|
||||
* The keys of the fields being updated (e.g. ["first_name", "email"]).
|
||||
* Used to restrict contact fields on protected users.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import userTypeAssignment from "./user-type-assignment";
|
||||
|
||||
type Params = {
|
||||
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
user_types: UserType[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import relativeTime from "./relative-time";
|
||||
|
||||
const NOW = new Date("2026-10-18T10:45:00").getTime();
|
||||
|
||||
describe("relativeTime", () => {
|
||||
test("under a minute is now", () => {
|
||||
expect(relativeTime({ at: NOW - 30_000, now: NOW })).toBe("now");
|
||||
});
|
||||
|
||||
test("future timestamps clamp to now", () => {
|
||||
expect(relativeTime({ at: NOW + 60_000, now: NOW })).toBe("now");
|
||||
});
|
||||
|
||||
test("minutes and hours", () => {
|
||||
expect(relativeTime({ at: NOW - 2 * 60_000, now: NOW })).toBe("2m");
|
||||
expect(relativeTime({ at: NOW - 3 * 3_600_000, now: NOW })).toBe("3h");
|
||||
});
|
||||
|
||||
test("within a week is the weekday", () => {
|
||||
const tuesday = new Date("2026-10-13T09:00:00").getTime();
|
||||
expect(relativeTime({ at: tuesday, now: NOW })).toBe("Tue");
|
||||
});
|
||||
|
||||
test("weeks and years", () => {
|
||||
expect(relativeTime({ at: NOW - 21 * 86_400_000, now: NOW })).toBe(
|
||||
"3w",
|
||||
);
|
||||
expect(relativeTime({ at: NOW - 800 * 86_400_000, now: NOW })).toBe(
|
||||
"2y",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Unix timestamp in milliseconds.
|
||||
*/
|
||||
at: number;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cobble timestamps are relative, never absolute: `now`, `2m`, `1h`, `Tue`,
|
||||
* then `3w` and `2y`.
|
||||
*/
|
||||
export default function relativeTime({ at, now = Date.now() }: Params): string {
|
||||
const diff = Math.max(0, now - at);
|
||||
|
||||
if (diff < MINUTE) return "now";
|
||||
if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m`;
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`;
|
||||
if (diff < WEEK) return WEEKDAYS[new Date(at).getDay()]!;
|
||||
if (diff < 52 * WEEK) return `${Math.floor(diff / WEEK)}w`;
|
||||
|
||||
return `${Math.floor(diff / (365 * DAY))}y`;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ApiReqParams } from "../types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "../types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "../types/sql-joins";
|
||||
|
||||
export default function searchableFieldToQueryField({
|
||||
field,
|
||||
}: {
|
||||
field: keyof BUN_SQLITE_BSQLITE_USERS_JOIN;
|
||||
field: keyof BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
}): keyof ApiReqParams {
|
||||
switch (field) {
|
||||
case "email":
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import textToSafeHtml from "./text-to-html";
|
||||
|
||||
test("textToSafeHtml passes HTML through unchanged", () => {
|
||||
const html = `<p>Payment may be split: <strong>30% deposit</strong>.</p>`;
|
||||
expect(textToSafeHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
test("textToSafeHtml converts legacy plain-text line breaks", () => {
|
||||
expect(textToSafeHtml("Line one\nLine two")).toBe(
|
||||
"Line one<br />Line two",
|
||||
);
|
||||
expect(textToSafeHtml("A\r\nB\r\nC")).toBe("A<br />B<br />C");
|
||||
});
|
||||
|
||||
test("textToSafeHtml handles empty and plain single-line input", () => {
|
||||
expect(textToSafeHtml("")).toBe("");
|
||||
expect(textToSafeHtml("Just a note")).toBe("Just a note");
|
||||
expect(textToSafeHtml(undefined)).toBe("");
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* # Convert a stored text field to an HTML fragment for rendering.
|
||||
* Documents that switched from plain text to rich HTML (e.g. invoice/quote
|
||||
* notes) may still hold legacy plain-text rows — those get their line breaks
|
||||
* converted to `<br />` so they render like they used to.
|
||||
*/
|
||||
export default function textToSafeHtml(text?: string): string {
|
||||
const value = text || "";
|
||||
|
||||
if (/<[a-z][^>]*>/i.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.replace(/\r?\n/g, "<br />");
|
||||
}
|
||||
Reference in New Issue
Block a user