feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages

This commit is contained in:
s.esmaeili
2026-10-04 07:11:52 +03:30
commit a001fa5d5f
302 changed files with 35491 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
export {
ASSET_KINDS,
Avatar,
AvatarGroup,
COMMENT_ORDERS,
COMMENT_TYPES,
CUSTOM_FIELD_TYPES,
CustomFieldValue,
DATE_PRESETS,
INBOX_CATEGORIES,
INBOX_STATES,
LiveError,
NOTIFICATION_TYPES,
PRIORITIES,
PriorityFlag,
RichText,
SEARCH_KINDS,
SEARCH_ORDERS,
STATUS_TYPES,
StatusBadge,
TASK_ORDERS,
Tag,
clickup,
keepPreviousData,
useComments,
useCustomFields,
useFolders,
useInbox,
useLists,
useMutation,
useReminders,
useSearch,
useSpaces,
useStatuses,
useTaskQuery,
useTaskTypes,
useTasks,
useTeams,
useUsers,
} from "./index.ts";
+2
View File
@@ -0,0 +1,2 @@
/** A hash of the kit's sources, written by the snapshot build; `"dev"` in the repo. */
export const KIT_BUILD = "0fce9c14";
+76
View File
@@ -0,0 +1,76 @@
import { KIT_BUILD } from "../build";
import type { LiveBridge } from "../host/bridge";
import type { Store } from "../store";
import { hydrate } from "./hydrate";
import { persist } from "./persist";
import { idbStorage, removeSiblings } from "./storage";
const KILL_SWITCH_KEY = "live-kit:cache";
const KILL_SWITCH_PARAM = "live-kit-cache";
const OFF = "off";
const PREFIX = "live-kit/";
/**
* Paints the viewer's last visit while this session's data loads, and writes
* this session's data back. Nothing here is on the network path: hydration
* only fills what the store has not read yet, one tab at a time writes, and
* any failure turns the cache off for the session without a trace.
*/
export function attachCache(store: Store, bridge: LiveBridge): void {
if (killed()) {
return;
}
const viewer = viewerOf(bridge);
if (viewer === undefined) {
return;
}
const name = `${PREFIX}${KIT_BUILD}/${viewer}`;
const storage = idbStorage(name);
hydrate(storage, store).then(() => {
void removeSiblings(PREFIX, name);
void asWriter(name, (release) => persist(storage, store, release));
}, noop);
}
/** Runs `start` once this tab holds the database's lock, and holds it until the tab closes or the persister gives up. */
async function asWriter(
name: string,
start: (release: () => void) => () => void,
): Promise<void> {
const locks = (navigator as { locks?: LockManager }).locks;
const hold = () =>
new Promise<void>((release) => {
const detach = start(release);
window.addEventListener("pagehide", () => {
detach();
release();
});
});
if (locks) {
await locks.request(name, hold);
} else {
await hold();
}
}
function killed(): boolean {
try {
return (
localStorage.getItem(KILL_SWITCH_KEY) === OFF ||
new URLSearchParams(location.search).get(KILL_SWITCH_PARAM) === OFF
);
} catch {
return true;
}
}
/** The viewer's id; a host without `getViewer` gets no cache. */
function viewerOf(bridge: LiveBridge): string | undefined {
try {
return bridge.viewer().id;
} catch {
return undefined;
}
}
function noop(): void {}
+96
View File
@@ -0,0 +1,96 @@
import type { Thing } from "../things";
/** A page on the wire: every ref nested in its things written once, referenced by `kind#id`. */
interface PageWire {
refs: Record<string, object>;
data: object[];
}
interface RefNode {
$ref: string;
}
export function encodePage(things: Thing[]): string {
const refs: Record<string, object> = {};
const compact = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(compact);
}
if (!isObject(value)) {
return value;
}
if (!isRef(value)) {
return compactFields(value);
}
const key = `${value.kind}#${value.id}`;
if (!(key in refs)) {
refs[key] = compactFields(value);
}
return { $ref: key } satisfies RefNode;
};
const compactFields = (value: object): object =>
Object.fromEntries(
Object.entries(value).map(([field, nested]) => [field, compact(nested)]),
);
const wire: PageWire = { refs, data: things.map(compactFields) };
return JSON.stringify(wire);
}
/** Throws when the text is not a page or a `$ref` names nothing. */
export function decodePage(text: string): Thing[] {
const parsed: unknown = JSON.parse(text);
if (!isObject(parsed)) {
throw new Error("Not a page");
}
const { refs, data } = parsed as PageWire;
if (!isObject(refs) || !Array.isArray(data)) {
throw new Error("Not a page");
}
const expand = (value: unknown): unknown => {
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
value[index] = expand(value[index]);
}
return value;
}
if (!isObject(value)) {
return value;
}
if (isRefNode(value)) {
const ref = refs[value.$ref];
if (!ref) {
throw new Error(`Unresolvable $ref ${value.$ref}`);
}
return { ...ref };
}
for (const field of Object.keys(value)) {
(value as Record<string, unknown>)[field] = expand(
(value as Record<string, unknown>)[field],
);
}
return value;
};
for (const key of Object.keys(refs)) {
refs[key] = expand(refs[key]) as object;
}
return data.map((thing) => {
const expanded = expand(thing);
if (!isObject(expanded) || !isRef(expanded)) {
throw new Error("Not a thing");
}
return expanded;
});
}
function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null;
}
function isRef(value: object): value is Thing {
const { kind, id } = value as Partial<Thing>;
return typeof kind === "string" && typeof id === "string";
}
function isRefNode(value: object): value is RefNode {
return typeof (value as Partial<RefNode>).$ref === "string";
}
+24
View File
@@ -0,0 +1,24 @@
import type { Store } from "../store";
import { decodePage } from "./codec";
import type { PageStorage } from "./storage";
/**
* Seeds the store from every page on disk, in key order, one page per task.
* A page that does not decode is removed and skipped; the rest still land.
*/
export async function hydrate(
storage: PageStorage,
store: Store,
): Promise<void> {
const broken: string[] = [];
await storage.readAll((key, text) => {
try {
store.seedPage(key, decodePage(text));
} catch {
broken.push(key);
}
});
for (const key of broken) {
await storage.remove(key);
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { Store } from "../store";
import { BUDGET_BYTES } from "../things";
import { encodePage } from "./codec";
import { whenIdle, yieldToMain } from "./schedule";
import type { PageStorage } from "./storage";
const IDLE_TIMEOUT_MS = 2_000;
/** Dispatched on `document` after every flush, so a test can await one. */
const FLUSH_EVENT = "live-kit:flush";
/**
* Writes the dirty pages in idle time after the store changes, and at once
* when the page is hidden. One flush runs at a time and changes during it
* coalesce into one more; a failure reports through `onError`, after which
* nothing more is written.
* @returns Detaches from the store and the page.
*/
export function persist(
storage: PageStorage,
store: Store,
onError: (error: unknown) => void,
): () => void {
let dirty = false;
let scheduled = false;
let flushing = false;
let detached = false;
const write = async () => {
store.evict(BUDGET_BYTES);
const puts: Array<[string, string]> = [];
const deletes: string[] = [];
for (const [page, things] of store.things.takeDirtyPages()) {
if (things.length === 0) {
deletes.push(page);
continue;
}
puts.push([page, encodePage(things)]);
await yieldToMain();
}
if (puts.length > 0 || deletes.length > 0) {
await storage.write(puts, deletes);
}
document.dispatchEvent(
new CustomEvent(FLUSH_EVENT, {
detail: { puts: puts.map(([key]) => key), deletes },
}),
);
};
const flush = () => {
if (flushing || detached) {
return;
}
dirty = false;
flushing = true;
write().then(
() => {
flushing = false;
if (dirty) {
schedule();
}
},
(error: unknown) => {
flushing = false;
detach();
onError(error);
},
);
};
const schedule = () => {
if (scheduled) {
return;
}
scheduled = true;
whenIdle(() => {
scheduled = false;
if (dirty) {
flush();
}
}, IDLE_TIMEOUT_MS);
};
const onHidden = () => {
if (document.visibilityState === "hidden" && dirty) {
flush();
}
};
const unsubscribe = store.onChange(() => {
dirty = true;
schedule();
});
document.addEventListener("visibilitychange", onHidden);
dirty = true;
schedule();
const detach = () => {
detached = true;
unsubscribe();
document.removeEventListener("visibilitychange", onHidden);
};
return detach;
}
+36
View File
@@ -0,0 +1,36 @@
interface YieldingScheduler {
yield?: () => Promise<void>;
}
/** Lets the main thread run pending input and paint before the next slice of cache work. */
export function yieldToMain(): Promise<void> {
const scheduler = (globalThis as { scheduler?: YieldingScheduler }).scheduler;
if (scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
resolve();
};
channel.port2.postMessage(undefined);
});
}
interface IdleWindow {
requestIdleCallback?: (
callback: () => void,
options: { timeout: number },
) => number;
}
/** Runs `task` when the page is idle, or after `timeout` at the latest. */
export function whenIdle(task: () => void, timeout: number): void {
const idle = (globalThis as IdleWindow).requestIdleCallback;
if (idle) {
idle(task, { timeout });
} else {
setTimeout(task, timeout);
}
}
+126
View File
@@ -0,0 +1,126 @@
const STORE = "pages";
const OPEN_DEADLINE_MS = 2_000;
/** Pages by key, each one encoded string; keys mean nothing here beyond their order. */
export interface PageStorage {
/** Visits every page in key order, one task per page, and resolves once the last has been visited. */
readAll(onPage: (key: string, text: string) => void): Promise<void>;
/** Applies the puts and deletes in one transaction. */
write(puts: Array<[string, string]>, deletes: string[]): Promise<void>;
/** Deletes the page, on its own transaction. */
remove(key: string): Promise<void>;
}
/**
* One IndexedDB database holding one object store. Every failure rejects,
* including a missing `indexedDB` and an `open` that does not answer within
* the deadline, and the caller decides what to do.
*/
export function idbStorage(name: string): PageStorage {
let database: Promise<IDBDatabase> | undefined;
const open = () => (database ??= openDatabase(name));
const storage: PageStorage = {
async readAll(onPage) {
const db = await open();
await new Promise<void>((resolve, reject) => {
const request = db
.transaction(STORE, "readonly")
.objectStore(STORE)
.openCursor();
request.onerror = () =>
reject(request.error ?? new Error("IDB read failed"));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
if (typeof cursor.value === "string") {
onPage(String(cursor.key), cursor.value);
}
cursor.continue();
};
});
},
async write(puts, deletes) {
const db = await open();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite", {
durability: "relaxed",
});
const store = tx.objectStore(STORE);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("IDB write failed"));
tx.onabort = () => reject(tx.error ?? new Error("IDB write aborted"));
for (const key of deletes) {
store.delete(key);
}
const remaining = puts.values();
const putNext = () => {
const next = remaining.next();
if (next.done) {
tx.commit?.();
return;
}
const [key, text] = next.value;
store.put(text, key).onsuccess = putNext;
};
putNext();
});
},
remove: (key) => storage.write([], [key]),
};
return storage;
}
/** Deletes every database whose name starts with `prefix` other than `keep`, where the browser can list them. */
export async function removeSiblings(
prefix: string,
keep: string,
): Promise<void> {
if (typeof indexedDB === "undefined" || !indexedDB.databases) {
return;
}
for (const { name } of await indexedDB.databases()) {
if (name && name !== keep && name.startsWith(prefix)) {
indexedDB.deleteDatabase(name);
}
}
}
function openDatabase(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (typeof indexedDB === "undefined") {
reject(new Error("IndexedDB is unavailable"));
return;
}
const deadline = setTimeout(
() => reject(new Error("IndexedDB did not open in time")),
OPEN_DEADLINE_MS,
);
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(name, 1);
} catch (error) {
clearTimeout(deadline);
reject(error);
return;
}
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE);
};
request.onsuccess = () => {
clearTimeout(deadline);
resolve(request.result);
};
request.onerror = () => {
clearTimeout(deadline);
reject(request.error ?? new Error("IndexedDB failed to open"));
};
request.onblocked = () => {
clearTimeout(deadline);
reject(new Error("IndexedDB open is blocked"));
};
});
}
+198
View File
@@ -0,0 +1,198 @@
import * as z from "zod/v4-mini";
import { attachCache } from "./cache/attach";
import { getComments, readComments } from "./comments/read";
import {
CommentPatchSchema,
CommentSelectorSchema,
CreateCommentInputSchema,
} from "./comments/schema";
import { createComment, updateComment } from "./comments/write";
import type { LiveClient } from "./core";
import { readCustomFields } from "./customFields/read";
import { CustomFieldSelectorSchema } from "./customFields/schema";
import {
getFolders,
getLists,
getSpaces,
readFolders,
readLists,
readSpaces,
} from "./hierarchy/read";
import { FolderSelectorSchema, ListSelectorSchema } from "./hierarchy/schema";
import type { LiveBridge } from "./host/bridge";
import { windowBridge } from "./host/bridge";
import { perform } from "./host/perform";
import { toHtml } from "./host/quill";
import {
ARTIFACT_NAVIGATION_CAPABILITIES,
ArtifactWriteActionType as Action,
} from "./host/wire";
import { getInbox, readInbox } from "./inbox/read";
import { InboxBundlePatchSchema, InboxSelectorSchema } from "./inbox/schema";
import { updateInboxBundle } from "./inbox/write";
import type { User } from "./people/people";
import {
getTeams,
getUsers,
mapMember,
readTeams,
readUsers,
} from "./people/read";
import { getReminders, readReminders } from "./reminders/read";
import {
CreateReminderInputSchema,
ReminderPatchSchema,
ReminderSelectorSchema,
} from "./reminders/schema";
import { createReminder, updateReminder } from "./reminders/write";
import { search } from "./search/read";
import { SearchQuerySchema } from "./search/schema";
import { createSession } from "./session";
import { Store } from "./store";
import { getTasks, queryTasks, readTasks } from "./tasks/read";
import { readStatuses } from "./tasks/statuses";
import { readTaskTypes } from "./tasks/taskTypes";
import {
CreateTaskInputSchema,
StatusSelectorSchema,
TaskPatchSchema,
TaskSelectorSchema,
} from "./tasks/schema";
import { createTask, updateTask } from "./tasks/write";
import { check, delta, refOrId, targets } from "./validate";
const url = z.string().check(z.minLength(1, "Expected a ClickUp url"));
const prompt = z.string().check(z.minLength(1, "Expected a prompt"));
const sql = z.string().check(z.minLength(1, "Expected a SELECT statement"));
let hostClient: LiveClient | undefined;
/**
* Reads and writes outside React: the same reads as the hooks, as promises,
* plus all writes. Use it in event handlers and plain modules.
*/
export const clickup: LiveClient = new Proxy({} as LiveClient, {
get: (_, key) => Reflect.get((hostClient ??= createLiveClient()), key),
});
/**
* @internal A client over any bridge; `clickup` is one over the window's
* host. Every input is checked against its schema here.
*/
export function createLiveClient(
bridge: LiveBridge = windowBridge(),
): LiveClient {
const store = new Store();
const s = createSession(bridge, store);
attachCache(store, bridge);
let viewer: User | undefined;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
store.reload();
}
});
return {
store,
spaces: {
list: async () => readSpaces(s),
get: async (spaces) => getSpaces(s, check(targets("space"), spaces)),
},
folders: {
list: async (selector) =>
readFolders(s, check(FolderSelectorSchema, selector)),
get: async (folders) => getFolders(s, check(targets("folder"), folders)),
},
lists: {
list: async (selector) =>
readLists(s, check(ListSelectorSchema, selector)),
get: async (lists) => getLists(s, check(targets("list"), lists)),
},
customFields: {
list: async (selector) =>
readCustomFields(s, check(CustomFieldSelectorSchema, selector)),
},
tasks: {
list: async (selector) =>
readTasks(s, check(TaskSelectorSchema, selector)),
get: async (tasks) => getTasks(s, check(targets("task"), tasks)),
create: async (input) =>
createTask(s, check(CreateTaskInputSchema, input)),
update: async (task, patch) =>
updateTask(
s,
check(refOrId("task"), task),
check(TaskPatchSchema, patch),
),
query: async (statement) => queryTasks(s, check(sql, statement)),
types: async () => readTaskTypes(s),
statuses: async (selector) =>
readStatuses(s, check(StatusSelectorSchema, selector)),
},
comments: {
list: async (selector) =>
readComments(s, check(CommentSelectorSchema, selector)),
get: async (comments) =>
getComments(s, check(targets("comment"), comments)),
create: async (input) =>
createComment(s, check(CreateCommentInputSchema, input)),
update: async (comment, patch) =>
updateComment(
s,
check(refOrId("comment"), comment),
check(CommentPatchSchema, patch),
),
},
users: {
list: async () => readUsers(s),
get: async (users) => getUsers(s, check(targets("user"), users)),
},
teams: {
list: async () => readTeams(s),
get: async (teams) => getTeams(s, check(targets("team"), teams)),
},
inbox: {
list: async (selector = {}) =>
readInbox(s, check(InboxSelectorSchema, selector)),
get: async (bundles) =>
getInbox(s, check(targets("inboxBundle"), bundles)),
update: async (bundle, patch) =>
updateInboxBundle(
s,
check(refOrId("inboxBundle"), bundle),
check(InboxBundlePatchSchema, patch),
),
},
reminders: {
list: async (selector = {}) =>
readReminders(s, check(ReminderSelectorSchema, selector)),
get: async (reminders) =>
getReminders(s, check(targets("reminder"), reminders)),
create: async (input) =>
createReminder(s, check(CreateReminderInputSchema, input)),
update: async (reminder, patch) =>
updateReminder(
s,
check(refOrId("reminder"), reminder),
check(ReminderPatchSchema, patch),
),
},
navigate: async (location) => {
await perform(bridge, ARTIFACT_NAVIGATION_CAPABILITIES.open, {
url: check(url, location),
});
},
search: async (query) => search(s, check(SearchQuerySchema, query)),
askBrain: async (text) => {
await perform(bridge, Action.AiPrompt, { prompt: check(prompt, text) });
},
toHtml: async (body) => toHtml(bridge, check(delta, body)),
get viewer() {
return (viewer ??= mapMember(bridge.viewer()));
},
};
}
if (typeof window !== "undefined" && "clickup" in window) {
hostClient = createLiveClient();
}
+97
View File
@@ -0,0 +1,97 @@
import type {
Delta,
IsoDate,
Ref,
UseQueryOptions,
UseQueryResult,
} from "../core";
import { listOrGet, useLiveQuery } from "../useQuery";
export const COMMENT_TYPES = ["message", "ai", "activity"] as const;
/**
* `name` is a one-line preview of the body. The last ancestor is the task,
* or for a reply the comment it replies to.
*/
export interface Comment extends Ref<"comment"> {
ancestors: Array<Ref<"space" | "folder" | "list" | "task" | "comment">>;
createdAt: IsoDate;
updatedAt: IsoDate;
type: (typeof COMMENT_TYPES)[number];
/** The body; render it with `RichText`. */
body: Delta;
creator: Ref<"user">;
assignee?: Ref<"user">;
/** Set once the comment is resolved; there is no separate resolved flag. */
resolvedBy?: Ref<"user">;
/** One entry per emoji, with the workspace members who reacted. */
reactions: Array<{ emoji: string; users: Ref<"user">[] }>;
}
/**
* `resolved` resolves or reopens the comment (read it back through
* `resolvedBy`), `assignee: null` unassigns, and `myReactions` replaces the
* viewer's reactions, leaving everyone else's.
*/
export interface CommentPatch {
/** Replaces the body; read it back as `body`. */
bodyMarkdown?: string;
assignee?: Ref<"user"> | null;
resolved?: boolean;
myReactions?: string[];
}
/** `parent` is the task to comment on, or the comment to reply to. */
export interface CreateCommentInput {
parent: Ref<"task" | "comment">;
/** The body, as markdown. */
bodyMarkdown: string;
assignee?: Ref<"user">;
}
export const COMMENT_ORDERS = ["createdAt"] as const;
/**
* Needs `parent` or `ancestor`. The other fields filter and sort on the
* server before `limit` applies.
* @example
* useComments({ ancestor: task, resolved: false, assignee: "me" });
*/
export interface CommentSelector {
/** A task, for its top-level comments; or a comment from `useComments`, for its replies. */
parent?: Ref<"task" | "comment">;
/** A task, for all its comments including replies. */
ancestor?: Ref<"task"> | string;
/** Defaults to 100, at most 500. */
limit?: number;
resolved?: boolean;
/** `"me"` is the viewer. */
assignee?: Ref<"user"> | "me";
creator?: Ref<"user"> | "me";
body?: { contains: string };
/** Oldest first when omitted; `direction` defaults to `"asc"`. */
orderBy?: {
field: (typeof COMMENT_ORDERS)[number];
direction?: "asc" | "desc";
};
}
export interface CommentsApi {
list(selector: CommentSelector): Promise<Comment[]>;
/** Comments on tasks; ids from chats or docs are left out. */
get(targets: Array<Ref<"comment"> | string>): Promise<Comment[]>;
create(input: CreateCommentInput): Promise<Ref<"comment">>;
update(comment: Ref<"comment"> | string, patch: CommentPatch): Promise<void>;
}
export function useComments(
selector: CommentSelector | Array<Ref<"comment"> | string>,
options: UseQueryOptions<Comment[]> = {},
): UseQueryResult<Comment[]> {
return useLiveQuery(
"comment",
selector,
(client) => listOrGet(client.comments, selector),
options,
);
}
+262
View File
@@ -0,0 +1,262 @@
import * as z from "zod/v4-mini";
import type { Ref } from "../core";
import { asRef, pathOf } from "../core";
import type { LiveBridge } from "../host/bridge";
import { toIsoOrEpoch } from "../host/dates";
import { chunk, pages } from "../host/paging";
import { paragraph, toEmoji } from "../host/quill";
import type {
ArtifactCommentDto,
ArtifactCommentReactionDto,
ArtifactCommentsQuery,
} from "../host/wire";
import { ARTIFACT_COMMENTS_MAX_LIMIT } from "../host/wire";
import { userOf } from "../people/read";
import { byIds, clampLimit } from "../select";
import type { Session } from "../session";
import { resolve } from "../session";
import { getTasks } from "../tasks/read";
import { fromHost } from "../validate";
import type { Comment, CommentSelector } from "./comments";
import { CommentSchema } from "./schema";
type CommentsInput = Omit<
Extract<ArtifactCommentsQuery, { parent: unknown }>,
"cursor" | "limit"
>;
/**
* The comments under the selector's parent, or every comment under its
* ancestor task. A reply read along with its root sits under that root,
* named as far as the result lists it.
*/
export async function readComments(
s: Session,
selector: CommentSelector,
): Promise<Comment[]> {
const limit = clampLimit(selector.limit);
const parent = await commentParent(s, selector);
const wire = await commentPages(
s.host,
{
parent: {
kind: parent.kind === "task" ? "task" : "thread",
id: parent.id,
},
includeReplies: selector.ancestor !== undefined,
resolved: selector.resolved,
assignedTo: selector.assignee && memberId(s, selector.assignee),
createdBy: selector.creator && memberId(s, selector.creator),
textSearch: selector.body?.contains,
orderDirection: selector.orderBy && (selector.orderBy.direction ?? "asc"),
},
limit,
);
const parentPath = [...pathOf(parent), asRef(parent)];
const roots = new Map(wire.map((dto) => [dto.id, commentRef(dto)]));
const comments = await mapComments(s, wire, (dto) =>
dto.parentId === parent.id
? parentPath
: [
...parentPath,
roots.get(dto.parentId) ?? {
kind: "comment",
id: dto.parentId,
name: "",
url: "",
},
],
);
return fromHost(z.array(CommentSchema), comments.slice(0, limit), "comments");
}
/** The selector's `parent`, or its `ancestor` task, as a full asset: a bare ref or id is read once for its path. */
function commentParent(
s: Session,
{ parent, ancestor }: CommentSelector,
): Promise<Ref<"task" | "comment">> {
return parent?.kind === "comment"
? resolve(s, "comment", parent, getComments)
: resolve(s, "task", parent ?? ancestor!, getTasks);
}
/**
* Exactly these comments, each under its task and, for a reply, its root
* comment. A comment elsewhere (a chat, a doc) or on a task the viewer
* cannot see is left out.
*/
export async function getComments(
s: Session,
ids: string[],
): Promise<Comment[]> {
const wire = await commentsByIds(s.host, ids);
const byId = await withRoots(s.host, wire);
const taskIds = wire.flatMap((dto) => taskIdOf(dto, byId) ?? []);
const tasks = new Map(
(await getTasks(s, [...new Set(taskIds)])).map((task) => [task.id, task]),
);
const comments = await mapComments(s, wire, (dto) => {
const task = tasks.get(taskIdOf(dto, byId) ?? "");
if (!task) {
return undefined;
}
const taskPath = [...pathOf(task), asRef(task)];
return dto.parentKind === "thread"
? [...taskPath, commentRef(byId.get(dto.parentId)!)]
: taskPath;
});
return fromHost(z.array(CommentSchema), byIds(comments, ids), "comments");
}
/** `wire` by id, plus the root comments its replies hang off, read where `wire` lacks them. */
async function withRoots(
host: LiveBridge,
wire: ArtifactCommentDto[],
): Promise<Map<string, ArtifactCommentDto>> {
const byId = new Map(wire.map((dto) => [dto.id, dto]));
const missing = wire
.filter((dto) => dto.parentKind === "thread" && !byId.has(dto.parentId))
.map((dto) => dto.parentId);
for (const root of await commentsByIds(host, [...new Set(missing)])) {
byId.set(root.id, root);
}
return byId;
}
/** The task a comment sits on, through its root for a reply; none for a comment elsewhere. */
function taskIdOf(
dto: ArtifactCommentDto,
byId: ReadonlyMap<string, ArtifactCommentDto>,
): string | undefined {
const onTask = dto.parentKind === "thread" ? byId.get(dto.parentId) : dto;
return onTask?.parentKind === "task" ? onTask.parentId : undefined;
}
async function commentsByIds(
host: LiveBridge,
ids: string[],
): Promise<ArtifactCommentDto[]> {
const results = await Promise.all(
chunk(ids, ARTIFACT_COMMENTS_MAX_LIMIT).map((batch) =>
host.query("comments", { ids: batch }),
),
);
return results.flatMap((result) => result.comments);
}
function memberId(s: Session, user: Ref<"user"> | "me"): string {
return user === "me" ? s.host.viewer().id : user.id;
}
async function commentPages(
host: LiveBridge,
input: CommentsInput,
limit: number,
): Promise<ArtifactCommentDto[]> {
const comments: ArtifactCommentDto[] = [];
for await (const page of pages((cursor) =>
host.query("comments", {
...input,
limit: Math.min(ARTIFACT_COMMENTS_MAX_LIMIT, limit - comments.length),
cursor,
}),
)) {
comments.push(...page.comments);
if (comments.length >= limit) {
break;
}
}
return comments;
}
/** Maps `wire` under the ancestors `place` gives each comment; one it places nowhere is left out. */
function mapComments(
s: Session,
wire: ArtifactCommentDto[],
place: (dto: ArtifactCommentDto) => Ref[] | undefined,
): Promise<Comment[]> {
return Promise.all(
wire.flatMap((dto) => {
const ancestors = place(dto);
return ancestors
? [mapComment(s, dto, ancestors as Comment["ancestors"])]
: [];
}),
);
}
function commentRef(dto: ArtifactCommentDto): Ref<"comment"> {
return {
kind: "comment",
id: dto.id,
name: preview(dto.bodyText),
url: dto.url,
};
}
async function mapComment(
s: Session,
wire: ArtifactCommentDto,
parentPath: Comment["ancestors"],
): Promise<Comment> {
const [creator, assignee, resolvedBy, reactions] = await Promise.all([
userOf(s, wire.creator),
wire.assigneeId ? s.member(wire.assigneeId) : undefined,
wire.resolved && wire.resolvedById
? s.member(wire.resolvedById)
: undefined,
mapReactions(s, wire.reactions),
]);
return {
kind: "comment",
id: wire.id,
name: preview(wire.bodyText),
url: wire.url,
ancestors: parentPath,
createdAt: toIsoOrEpoch(wire.createdAt),
updatedAt: toIsoOrEpoch(wire.updatedAt),
type: toCommentType(wire.commentType),
body: wire.bodyQuill ?? paragraph(wire.bodyMarkdown || wire.bodyText),
creator,
assignee,
resolvedBy,
reactions,
};
}
/** One entry per emoji, in first-seen order; reactors the workspace cannot name are left out. */
async function mapReactions(
s: Session,
wire: readonly ArtifactCommentReactionDto[],
): Promise<Comment["reactions"]> {
const byEmoji = new Map<string, Ref<"user">[]>();
for (const reaction of wire) {
const emoji = toEmoji(reaction.emoji);
const users = byEmoji.get(emoji) ?? [];
byEmoji.set(emoji, users);
const user = reaction.userId ? await s.member(reaction.userId) : undefined;
if (user && !users.some((known) => known.id === user.id)) {
users.push(user);
}
}
return [...byEmoji].map(([emoji, users]) => ({ emoji, users }));
}
const PREVIEW_MAX_LENGTH = 120;
/** A comment's `name`: its body on one line, cut to a headline. */
export function preview(text: string): string {
const line = text.replace(/\s+/g, " ").trim();
return line.length > PREVIEW_MAX_LENGTH
? `${line.slice(0, PREVIEW_MAX_LENGTH - 1)}…`
: line;
}
function toCommentType(value: string): Comment["type"] {
switch (value) {
case "ai":
case "activity":
return value;
default:
return "message";
}
}
+77
View File
@@ -0,0 +1,77 @@
import * as z from "zod/v4-mini";
import {
contains,
delta,
isoDate,
orderBy,
ref,
refFields,
refOrId,
schemaOf,
scoped,
selection,
} from "../validate";
import type {
Comment,
CommentPatch,
CommentSelector,
CreateCommentInput,
} from "./comments";
import { COMMENT_ORDERS, COMMENT_TYPES } from "./comments";
export const CommentSchema = schemaOf<Comment>()(
z.object({
...refFields(["comment"]),
ancestors: z.array(ref(["space", "folder", "list", "task", "comment"])),
createdAt: isoDate,
updatedAt: isoDate,
type: z.enum(COMMENT_TYPES),
body: delta,
creator: ref(["user"]),
assignee: z.optional(ref(["user"])),
resolvedBy: z.optional(ref(["user"])),
reactions: z.array(
z.object({ emoji: z.string(), users: z.array(ref(["user"])) }),
),
}),
);
const emoji = z.string().check(z.minLength(1, "Expected an emoji"));
export const CommentPatchSchema = schemaOf<CommentPatch>()(
z.strictObject({
bodyMarkdown: z.optional(
z.string().check(z.minLength(1, "A comment needs a body")),
),
assignee: z.optional(z.nullable(ref(["user"]))),
resolved: z.optional(z.boolean()),
myReactions: z.optional(z.array(emoji)),
}),
);
export const CreateCommentInputSchema = schemaOf<CreateCommentInput>()(
z.strictObject({
parent: ref(["task", "comment"]),
bodyMarkdown: z.string().check(z.minLength(1, "A comment needs a body")),
assignee: z.optional(ref(["user"])),
}),
);
const userOrMe = z.union([ref(["user"]), z.literal("me")], {
error: 'Expected a user ref, from useUsers, or "me"',
});
export const CommentSelectorSchema = schemaOf<CommentSelector>()(
z
.strictObject({
parent: z.optional(ref(["task", "comment"])),
ancestor: z.optional(refOrId("task")),
...selection,
resolved: z.optional(z.boolean()),
assignee: z.optional(userOrMe),
creator: z.optional(userOrMe),
body: contains(),
orderBy: orderBy(COMMENT_ORDERS),
})
.check(scoped),
);
+156
View File
@@ -0,0 +1,156 @@
import type { Ref } from "../core";
import { asRef, LiveError, pathOf } from "../core";
import type { WireCall } from "../host/perform";
import { createdRef, perform, performAll } from "../host/perform";
import { paragraph } from "../host/quill";
import { ArtifactWriteActionType as Action } from "../host/wire";
import { mapMember } from "../people/read";
import type { Session } from "../session";
import { resolve } from "../session";
import { applyPatch } from "../store";
import type { Comment, CommentPatch, CreateCommentInput } from "./comments";
import { getComments, preview } from "./read";
export async function updateComment(
s: Session,
target: Ref<"comment"> | string,
patch: CommentPatch,
): Promise<void> {
const comment = await resolve<Comment>(s, "comment", target, getComments);
const viewer =
patch.myReactions === undefined ? undefined : mapMember(s.host.viewer());
await s.store.update(
comment,
(current) => applyCommentPatch(current, patch, viewer),
() => performAll(s.host, commentPatchCalls(comment, patch, viewer)),
);
}
/** Posts a comment on a task, or a reply under a comment (a `thread` to the host). */
export async function createComment(
s: Session,
input: CreateCommentInput,
): Promise<Ref<"comment">> {
const { parent } = input;
const outcome = await perform(s.host, Action.CreateComment, {
parentKind: parent.kind === "task" ? "task" : "thread",
parentId: parent.id,
commentText: input.bodyMarkdown,
});
const created = createdRef("comment", outcome, preview(input.bodyMarkdown));
if (input.assignee) {
await perform(s.host, Action.AssignComment, {
...commentTarget(created.id, [...pathOf(parent), asRef(parent)]),
assigneeUserId: input.assignee.id,
});
}
await s.store.invalidate("comment");
return created;
}
/**
* The ordered host calls that apply `patch` to `comment`. The host toggles a
* reaction, so every emoji the viewer adds or drops is one `react` call.
*/
export function commentPatchCalls(
comment: Comment,
patch: CommentPatch,
viewer: Ref<"user"> | undefined,
): WireCall[] {
const target = commentTarget(comment.id, comment.ancestors);
const calls: WireCall[] = [];
if (patch.bodyMarkdown !== undefined) {
calls.push([Action.EditComment, { ...target, text: patch.bodyMarkdown }]);
}
if (patch.resolved === true) {
calls.push([Action.ResolveComment, target]);
} else if (patch.resolved === false) {
calls.push([Action.ReopenComment, target]);
}
if (patch.assignee === null) {
calls.push([Action.UnassignComment, target]);
} else if (patch.assignee) {
calls.push([
Action.AssignComment,
{ ...target, assigneeUserId: patch.assignee.id },
]);
}
if (patch.myReactions !== undefined && viewer) {
const current = myReactions(comment, viewer);
const next = new Set(patch.myReactions);
for (const emoji of [...next, ...current]) {
if (next.has(emoji) !== current.has(emoji)) {
calls.push([Action.ReactToComment, { ...target, emoji }]);
}
}
}
return calls;
}
function myReactions(comment: Comment, viewer: Ref<"user">): Set<string> {
return new Set(
comment.reactions
.filter((reaction) =>
reaction.users.some((user) => user.id === viewer.id),
)
.map((reaction) => reaction.emoji),
);
}
/** The host addresses a comment by its task, and a reply by its root comment plus the reply id. */
function commentTarget(
id: string,
ancestors: readonly Ref[],
): Record<string, string> {
const task = ancestors.find((ref) => ref.kind === "task");
if (!task) {
throw new LiveError("invalid", "Only comments on tasks can be acted on.");
}
const parent = ancestors[ancestors.length - 1];
return parent?.kind === "comment"
? { taskId: task.id, commentId: parent.id, threadedCommentId: id }
: { taskId: task.id, commentId: id };
}
/** The comment as it will read back once the host has applied `patch`; `resolvedBy` and a rendered body arrive with the refetch that follows. */
export function applyCommentPatch(
comment: Comment,
patch: CommentPatch,
viewer: Ref<"user"> | undefined,
): Comment {
const { bodyMarkdown, resolved, myReactions: mine, ...fields } = patch;
const next = applyPatch(comment, fields);
if (bodyMarkdown !== undefined) {
next.name = preview(bodyMarkdown);
next.body = paragraph(bodyMarkdown);
}
if (resolved === false) {
delete next.resolvedBy;
}
if (mine !== undefined && viewer) {
next.reactions = withViewerReactions(comment.reactions, viewer, mine);
}
return next;
}
/** `reactions` with the viewer on exactly the `emojis`; an emoji nobody reacts with anymore goes. */
function withViewerReactions(
reactions: Comment["reactions"],
viewer: Ref<"user">,
emojis: string[],
): Comment["reactions"] {
const wanted = new Set(emojis);
const kept = reactions
.map(({ emoji, users }) => {
const others = users.filter((user) => user.id !== viewer.id);
return { emoji, users: wanted.has(emoji) ? [...others, viewer] : others };
})
.filter((reaction) => reaction.users.length > 0);
const known = new Set(kept.map((reaction) => reaction.emoji));
return [
...kept,
...emojis
.filter((emoji) => !known.has(emoji))
.map((emoji) => ({ emoji, users: [viewer] })),
];
}
+411
View File
@@ -0,0 +1,411 @@
import type { CSSProperties, ReactElement, ReactNode } from "react";
import type { Delta, Ref } from "../core";
import type { CustomField } from "../customFields/customFields";
import type { Status, Task } from "../tasks/tasks";
import { useLiveQuery } from "../useQuery";
import { CheckIcon, FlagIcon, NoPriorityIcon, PeopleIcon } from "./icons";
import {
avatarColor,
cx,
fill,
initials,
PRIORITY_COLORS,
PRIORITY_LABELS,
} from "./palette";
export interface AvatarProps {
/** A user or team, e.g. `task.creator` or one of `task.assignees`. */
of: Ref<"user" | "team">;
/** Diameter in px; 28 by default. */
size?: number;
className?: string;
}
const DEFAULT_AVATAR_SIZE = 28;
const MIN_INITIALS_PX = 12;
function avatarStyle(size: number): CSSProperties {
return {
fontSize: Math.max(MIN_INITIALS_PX, Math.round(size * 0.42)),
height: size,
width: size,
};
}
const AVATAR_CLASS =
"inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full font-semibold leading-none tracking-tight";
/** A round avatar: the image, else initials for a user or a people glyph for a team. */
export function Avatar({
of,
size = DEFAULT_AVATAR_SIZE,
className,
}: AvatarProps): ReactElement {
const base = cx(AVATAR_CLASS, className);
if (of.image) {
return (
<span className={base} style={avatarStyle(size)} title={of.name}>
<img
alt={of.name}
className="h-full w-full object-cover"
src={of.image}
/>
</span>
);
}
const { background, text } = fill(avatarColor(of.id))!;
return (
<span
aria-label={of.name}
className={base}
role="img"
style={{ ...avatarStyle(size), backgroundColor: background, color: text }}
title={of.name}
>
{of.kind === "team" ? (
<PeopleIcon height="1.25em" width="1.25em" />
) : (
initials(of.name)
)}
</span>
);
}
export interface AvatarGroupProps {
/** Users and teams, e.g. `task.assignees`. */
of: Array<Ref<"user" | "team">>;
/** How many to show before the rest collapse into a `+N` bubble; 3 by default. */
max?: number;
/** Diameter of each avatar in px; 28 by default. */
size?: number;
className?: string;
}
/** Overlapping avatars with a `+N` bubble for the overflow; renders nothing for an empty list. */
export function AvatarGroup({
of,
max = 3,
size = DEFAULT_AVATAR_SIZE,
className,
}: AvatarGroupProps): ReactElement | null {
if (of.length === 0) {
return null;
}
const shown = of.length > max ? of.slice(0, max) : of;
const hidden = of.length - shown.length;
const overlap = { marginLeft: -Math.round(size / 5) };
return (
<span className={cx("inline-flex items-center", className)}>
{shown.map((member, index) => (
<span
className="inline-flex"
key={`${member.kind}:${member.id}`}
style={index > 0 ? overlap : undefined}
>
<Avatar className="ring-2 ring-background" of={member} size={size} />
</span>
))}
{hidden > 0 && (
<span
className={cx(
AVATAR_CLASS,
"bg-muted text-muted-foreground ring-2 ring-background",
)}
style={{ ...avatarStyle(size), ...overlap }}
>
+{hidden}
</span>
)}
</span>
);
}
export interface StatusBadgeProps {
/** `task.status`, or one from `useStatuses`. */
status: Status;
className?: string;
}
/** The status name as ClickUp's filled, uppercase badge. Sized by the surrounding font; `className="text-xs"` makes it compact. */
export function StatusBadge({
status,
className,
}: StatusBadgeProps): ReactElement {
return (
<Pill
className={cx(
"rounded-md px-[0.7em] py-[0.36em] font-semibold uppercase tracking-wide",
className,
)}
color={status.color}
>
{status.name}
</Pill>
);
}
export interface PriorityFlagProps {
/** `task.priority`; `undefined` renders nothing unless `showEmpty`. */
priority: Task["priority"];
/** Renders a slashed circle for `undefined`; off by default. */
showEmpty?: boolean;
className?: string;
}
/** ClickUp's priority flag, without a label. Sized by the surrounding font. */
export function PriorityFlag({
priority,
showEmpty = false,
className,
}: PriorityFlagProps): ReactElement | null {
if (!priority && !showEmpty) {
return null;
}
const color =
priority && priority !== "low" ? PRIORITY_COLORS[priority] : undefined;
return (
<span
aria-label={priority ? PRIORITY_LABELS[priority] : "No priority"}
className={cx(
"inline-flex items-center",
!color && "text-muted-foreground",
className,
)}
role="img"
>
{priority ? (
<FlagIcon style={color ? { color } : undefined} />
) : (
<NoPriorityIcon />
)}
</span>
);
}
export interface TagProps {
/** One of `task.tags`. */
tag: Task["tags"][number];
className?: string;
}
/** A tag as ClickUp's filled pill. Sized by the surrounding font. */
export function Tag({ tag, className }: TagProps): ReactElement {
return (
<Pill
className={cx("rounded-full px-[0.85em] py-[0.32em]", className)}
color={tag.color}
>
{tag.name}
</Pill>
);
}
export interface CustomFieldValueProps {
/** One of `task.customFields`. */
value: Task["customFields"][number];
/** The field's definition from `useCustomFields`, matched by `id`; adds the option colors. */
field?: CustomField;
className?: string;
}
const PROGRESS_TYPES = new Set<CustomField["type"]>([
"manual_progress",
"automatic_progress",
]);
/**
* A custom field value in the shape its type calls for: a check, a number, a
* progress bar, a date, colored option pills, avatars for people, names for
* tasks, links for attachments. Sized by the surrounding font.
*/
export function CustomFieldValue({
value,
field,
className,
}: CustomFieldValueProps): ReactElement {
return (
<span
className={cx(
"inline-flex items-center gap-1 text-foreground",
className,
)}
>
{renderFieldValue(value, field)}
</span>
);
}
function renderFieldValue(
{ type, value }: Task["customFields"][number],
field: CustomField | undefined,
): ReactNode {
if (typeof value === "boolean") {
return value ? (
<CheckIcon aria-hidden={false} aria-label="Yes" role="img" />
) : (
<span aria-label="No" className="text-muted-foreground" role="img">
–
</span>
);
}
if (typeof value === "number") {
return PROGRESS_TYPES.has(type) ? (
<Progress percent={value} />
) : (
value.toLocaleString()
);
}
if (typeof value === "string") {
if (type === "date") {
return formatDate(value);
}
if (type === "drop_down") {
return <OptionPill field={field} name={value} />;
}
return value;
}
if (type === "attachment") {
return (value as string[]).map((url) => (
<a
className="underline"
href={url}
key={url}
rel="noreferrer"
target="_blank"
>
{fileName(url)}
</a>
));
}
if (type === "labels") {
return (value as string[]).map((name) => (
<OptionPill field={field} key={name} name={name} />
));
}
const refs = value as Array<Ref<"user" | "team" | "task">>;
if (type === "users") {
return <AvatarGroup of={refs as Array<Ref<"user" | "team">>} size={24} />;
}
return refs.map((ref) => ref.name).join(", ");
}
function fileName(url: string): string {
const last = url.split("?")[0]!.split("/").pop();
return last ? decodeURIComponent(last) : url;
}
function OptionPill({
field,
name,
}: {
field: CustomField | undefined;
name: string;
}) {
const color = field?.options?.find((option) => option.name === name)?.color;
return (
<Pill className="rounded-full px-[0.75em] py-[0.25em]" color={color}>
{name}
</Pill>
);
}
function Progress({ percent }: { percent: number }) {
const clamped = Math.max(0, Math.min(100, percent));
return (
<>
<span
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={clamped}
className="inline-block h-1.5 w-24 overflow-hidden rounded-full bg-muted"
role="progressbar"
>
<span
className="block h-full bg-foreground"
style={{ width: `${clamped}%` }}
/>
</span>
<span className="text-muted-foreground">{Math.round(clamped)}%</span>
</>
);
}
function formatDate(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
}
/** A filled pill with readable text; an unusable color falls back to the muted token. */
function Pill({
children,
className,
color,
}: {
children: ReactNode;
className: string;
color: string | undefined;
}) {
const resolved = color ? fill(color) : undefined;
const style: CSSProperties | undefined = resolved
? { backgroundColor: resolved.background, color: resolved.text }
: undefined;
return (
<span
className={cx(
"inline-flex items-center whitespace-nowrap leading-none",
!resolved && "bg-muted text-foreground",
(!resolved || resolved.edge) && "ring-1 ring-inset ring-foreground/15",
className,
)}
style={style}
>
{children}
</span>
);
}
export interface RichTextProps {
/** A task or comment `body`. */
value: Delta;
className?: string;
}
/**
* A body as ClickUp shows it, rendered by the host: mentions, tables, embeds
* and the rest. Empty while the HTML loads; plain text if the host cannot
* render it.
*/
export function RichText({ value, className }: RichTextProps): ReactElement {
const { data, isError } = useLiveQuery(
"html",
value,
async (client) => [{ html: await client.toHtml(value) }],
{ cache: false },
);
const classes = cx("prose prose-sm max-w-none dark:prose-invert", className);
if (isError) {
return (
<div className={cx(classes, "whitespace-pre-wrap")}>
{plainText(value)}
</div>
);
}
return (
<div
className={classes}
dangerouslySetInnerHTML={{ __html: data?.[0]?.html ?? "" }}
/>
);
}
function plainText({ ops }: Delta): string {
return ops
.map((op) => (typeof op.insert === "string" ? op.insert : ""))
.join("");
}
+43
View File
@@ -0,0 +1,43 @@
import type { SVGProps } from "react";
type IconProps = Omit<SVGProps<SVGSVGElement>, "children" | "viewBox">;
const base = {
"aria-hidden": true,
fill: "currentColor",
height: "1em",
viewBox: "0 0 24 24",
width: "1em",
} as const;
export function FlagIcon(props: IconProps) {
return (
<svg {...base} {...props}>
<path d="M5 21a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.1a1 1 0 0 1 .98.8L15.4 5.5H20a1 1 0 0 1 1 1V15a1 1 0 0 1-1 1h-5.9a1 1 0 0 1-.98-.8L12.8 13.5H6V20a1 1 0 0 1-1 1Z" />
</svg>
);
}
export function NoPriorityIcon(props: IconProps) {
return (
<svg {...base} {...props}>
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 2a8 8 0 0 0-6.32 12.9L16.9 5.68A7.96 7.96 0 0 0 12 4Zm6.32 3.1L7.1 18.32A8 8 0 0 0 18.32 7.1Z" />
</svg>
);
}
export function PeopleIcon(props: IconProps) {
return (
<svg {...base} {...props}>
<path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z" />
</svg>
);
}
export function CheckIcon(props: IconProps) {
return (
<svg {...base} {...props}>
<path d="M9.55 17.65 4.3 12.4l1.4-1.4 3.85 3.85 8.75-8.75 1.4 1.4Z" />
</svg>
);
}
+196
View File
@@ -0,0 +1,196 @@
import type { Status } from "../tasks/tasks";
import type { Task } from "../tasks/tasks";
export type Rgb = readonly [number, number, number];
/** A background with a text color that reads on it; `edge` marks fills near white or black. */
export interface Fill {
background: string;
text: string;
edge: boolean;
}
/** The APCA Lc body text needs. */
export const READABLE_LC = 66;
const WHITE: Rgb = [255, 255, 255];
const BLACK: Rgb = [0, 0, 0];
const INK: Rgb = [17, 24, 39];
const NUDGE_STEPS = 10;
const EDGE_LUMINANCE = { dark: 0.04, light: 0.7 };
/**
* The fill for a workspace color: white or near-black text, whichever reads
* better, over the color darkened or lightened just enough for that to hold;
* `undefined` when the color cannot be parsed.
*/
export function fill(color: string): Fill | undefined {
const source = parseColor(color);
if (!source) {
return undefined;
}
const towards = bestText(source) === WHITE ? BLACK : WHITE;
for (let step = 0; step <= NUDGE_STEPS; step++) {
const background = mix(source, towards, step / NUDGE_STEPS);
const text = bestText(background);
if (Math.abs(apcaContrast(text, background)) >= READABLE_LC) {
return toFill(background, text);
}
}
return toFill(towards, towards === BLACK ? WHITE : INK);
}
function toFill(background: Rgb, text: Rgb): Fill {
const luminance = screenLuminance(background);
return {
background: toHex(background),
text: toHex(text),
edge: luminance > EDGE_LUMINANCE.light || luminance < EDGE_LUMINANCE.dark,
};
}
function bestText(background: Rgb): Rgb {
const white = Math.abs(apcaContrast(WHITE, background));
const ink = Math.abs(apcaContrast(INK, background));
return white >= ink ? WHITE : INK;
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const channel = (index: 0 | 1 | 2) =>
Math.round(from[index] + (to[index] - from[index]) * amount);
return [channel(0), channel(1), channel(2)];
}
const HEX_PATTERN = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const RGB_PATTERN =
/^rgba?\(\s*(\d{1,3}(?:\.\d+)?)[\s,]+(\d{1,3}(?:\.\d+)?)[\s,]+(\d{1,3}(?:\.\d+)?)(?:\s*[,/]\s*[\d.]+%?)?\s*\)$/i;
/** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` and `rgba()`; alpha is ignored. */
export function parseColor(value: string): Rgb | undefined {
const color = value.trim();
const hex = HEX_PATTERN.exec(color)?.[1];
if (hex) {
const digits = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
const number = Number.parseInt(digits.slice(0, 6), 16);
return [(number >> 16) & 0xff, (number >> 8) & 0xff, number & 0xff];
}
const rgb = RGB_PATTERN.exec(color);
if (rgb) {
const [red, green, blue] = rgb.slice(1, 4).map(Number) as [
number,
number,
number,
];
if (Math.max(red, green, blue) <= 255) {
return [Math.round(red), Math.round(green), Math.round(blue)];
}
}
return undefined;
}
export function toHex([red, green, blue]: Rgb): string {
return `#${[red, green, blue]
.map((channel) => channel.toString(16).padStart(2, "0"))
.join("")}`;
}
/**
* APCA-W3 0.0.98G-4g lightness contrast: positive for dark-on-light, negative
* for light-on-dark; |Lc| runs 0 to about 108, higher reads better.
*/
export function apcaContrast(text: Rgb, background: Rgb): number {
const textY = softClampBlack(screenLuminance(text));
const backgroundY = softClampBlack(screenLuminance(background));
if (Math.abs(backgroundY - textY) < 0.0005) {
return 0;
}
if (backgroundY > textY) {
const sapc = (backgroundY ** 0.56 - textY ** 0.57) * 1.14;
return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
}
const sapc = (backgroundY ** 0.65 - textY ** 0.62) * 1.14;
return sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
}
function screenLuminance([red, green, blue]: Rgb): number {
const linear = (channel: number) => (channel / 255) ** 2.4;
return (
0.2126729 * linear(red) +
0.7151522 * linear(green) +
0.072175 * linear(blue)
);
}
function softClampBlack(luminance: number): number {
return luminance > 0.022
? luminance
: luminance + (0.022 - luminance) ** 1.414;
}
/** ClickUp's priority colors; `low` has none and uses the muted text token. */
export const PRIORITY_COLORS: Readonly<
Record<Exclude<NonNullable<Task["priority"]>, "low">, string>
> = {
urgent: "#c62a2f",
high: "#ffc53d",
normal: "#3e63dd",
};
/** ClickUp's gray, for tags and field options given no color. */
export const DEFAULT_COLOR = "#87909e";
/** ClickUp's default status colors, for statuses whose list gave them none. */
export const STATUS_TYPE_COLORS: Readonly<Record<Status["type"], string>> = {
open: DEFAULT_COLOR,
active: "#5f55ee",
done: "#008844",
closed: "#656f7d",
};
export const PRIORITY_LABELS: Readonly<
Record<NonNullable<Task["priority"]>, string>
> = {
urgent: "Urgent",
high: "High",
normal: "Normal",
low: "Low",
};
/** ClickUp's avatar color picker; `avatarColor` picks one by id. */
export const AVATAR_COLORS: readonly string[] = [
"#6647f0",
"#3e63dd",
"#0091ff",
"#12a594",
"#248f7d",
"#ffc53d",
"#f76808",
"#e5484d",
"#e93d82",
"#ab4aba",
"#a18072",
];
export function avatarColor(id: string): string {
let hash = 5381;
for (const char of id) {
hash = (hash * 33) ^ char.charCodeAt(0);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]!;
}
/** The first letter of the first two words; `?` for an empty name. */
export function initials(name: string): string {
const letters = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0]!.toUpperCase());
return letters.join("") || "?";
}
export function cx(
...parts: ReadonlyArray<string | false | null | undefined>
): string {
return parts.filter(Boolean).join(" ");
}
+321
View File
@@ -0,0 +1,321 @@
import { useCallback, useRef, useState } from "react";
import type { CommentsApi } from "./comments/comments";
import type { CustomFieldsApi } from "./customFields/customFields";
import type { FoldersApi, ListsApi, SpacesApi } from "./hierarchy/hierarchy";
import type { InboxApi } from "./inbox/inbox";
import type { TeamsApi, User, UsersApi } from "./people/people";
import type { RemindersApi } from "./reminders/reminders";
import type { SearchHit, SearchQuery } from "./search/search";
import type { Store } from "./store";
import type { TasksApi } from "./tasks/tasks";
/**
* An ISO 8601 string as `Date#toISOString()` writes it; a plain `YYYY-MM-DD`
* is accepted as input and means that day in the viewer's timezone. A
* required date the host has no value for reads as the Unix epoch,
* `1970-01-01T00:00:00.000Z`.
*/
export type IsoDate = string;
/** Rich text as a Quill delta, plain JSON ops; render it with `RichText`. */
export interface Delta {
ops: ReadonlyArray<Record<string, unknown>>;
}
export const ASSET_KINDS = [
"space",
"folder",
"list",
"status",
"customField",
"task",
"taskType",
"comment",
"user",
"team",
"inboxBundle",
"reminder",
] as const;
export type AssetKind = (typeof ASSET_KINDS)[number];
/**
* A pointer to an asset; every full asset (a `Task`, a `List`, …) is also a
* `Ref`, so anything a hook returns can be passed where a ref is expected.
* Where a parameter is `Ref | string`, the string is the asset's id.
*/
export interface Ref<K extends AssetKind = AssetKind> {
kind: K;
id: string;
/** Empty when the viewer cannot see the asset, e.g. a related task in a private list. */
name: string;
/**
* Link to the asset in ClickUp; empty for statuses, custom fields and
* reminders. Users and teams link to the workspace's People page.
*/
url: string;
/** Avatar or thumbnail, where the asset has one. */
image?: string;
}
/** @internal */
export function idOf(input: Ref | string): string {
return typeof input === "string" ? input : input.id;
}
/** @internal */
export function asRef<K extends AssetKind>(asset: Ref<K>): Ref<K> {
const { kind, id, name, url, image } = asset;
return { kind, id, name, url, image };
}
/** @internal The `ancestors` of a full asset; none for a bare ref. */
export function pathOf(ref: Ref): Ref[] {
const ancestors = (ref as { ancestors?: unknown }).ancestors;
return Array.isArray(ancestors) ? (ancestors as Ref[]) : [];
}
/** `pending` until the first result or failure. */
export type QueryStatus = "pending" | "error" | "success";
/** TanStack Query v5's `useQuery` result. */
export interface UseQueryResult<T> {
/** Undefined until the first result; kept while refetching. */
data: T | undefined;
error: LiveError | null;
status: QueryStatus;
isPending: boolean;
/** Pending and fetching: the first result is on its way. */
isLoading: boolean;
/** True while a request is in flight, including the refresh behind a cached result. */
isFetching: boolean;
/** Fetching with data already showing. */
isRefetching: boolean;
/** True while `data` is the option's `placeholderData`, not a result. */
isPlaceholderData: boolean;
isError: boolean;
isSuccess: boolean;
/** When `data` was read from ClickUp, as a timestamp; 0 without data. */
dataUpdatedAt: number;
/** Reloads from ClickUp. Rarely needed, since writes refetch what they change. */
refetch: () => Promise<void>;
}
export interface UseQueryOptions<T = unknown> {
/** Pass false to skip fetching, e.g. while an input is still unknown. */
enabled?: boolean;
/** Shown as `data` until the first result, e.g. `keepPreviousData` to keep the last rows while a changed selector loads. */
placeholderData?: T | ((previous: T | undefined) => T | undefined);
/** Milliseconds between refetches while the tab is visible; never under 10 000. Rarely needed: writes refetch, and so does returning to the tab. */
refetchInterval?: number;
/** Pass false to wait for fresh data instead of painting the viewer's last result first. Rarely needed: the data refreshes anyway. */
cache?: boolean;
/** @internal */
client?: LiveClient;
}
/** A `placeholderData` that keeps the previous selector's rows on screen while the new ones load. */
export function keepPreviousData<T>(previous: T | undefined): T | undefined {
return previous;
}
/**
* - `denied`: the viewer declined the approval in ClickUp.
* - `forbidden`: the host or viewer is not allowed to; hide or disable the feature.
* - `invalid`: the call itself is wrong (selector, id or input); fix the code.
* - `failed`: something went wrong; retry when `retryable`.
*/
export type LiveErrorCode = "denied" | "forbidden" | "invalid" | "failed";
export class LiveError extends Error {
readonly code: LiveErrorCode;
/** True for rate limits, timeouts and other transient errors. */
readonly retryable: boolean;
/** The host's raw reason, for debugging. */
readonly reason?: string;
constructor(
code: LiveErrorCode,
message: string,
options: { retryable?: boolean; reason?: string } = {},
) {
super(message);
this.name = "LiveError";
this.code = code;
this.retryable = options.retryable ?? false;
if (options.reason !== undefined) {
this.reason = options.reason;
}
}
}
/**
* The same reads as the hooks, as promises, plus all writes. Each changed
* field needs the viewer's approval in ClickUp; a write stops at the first
* denial.
*/
export interface LiveClient {
/** @internal */
readonly store: Store;
spaces: SpacesApi;
folders: FoldersApi;
lists: ListsApi;
customFields: CustomFieldsApi;
tasks: TasksApi;
comments: CommentsApi;
users: UsersApi;
teams: TeamsApi;
inbox: InboxApi;
reminders: RemindersApi;
/** Opens a ClickUp url in the app. */
navigate(url: string): Promise<void>;
/** ClickUp's own search, run as the viewer. */
search(query: SearchQuery): Promise<SearchHit[]>;
/** Sends a prompt to Brain, ClickUp's AI, on the user's behalf. */
askBrain(prompt: string): Promise<void>;
/** A task or comment `body` as the HTML ClickUp shows for it; `RichText` renders this. */
toHtml(body: Delta): Promise<string>;
/** The user viewing the artifact, known to the host up front: no read, no loading state. */
readonly viewer: User;
}
export type MutationStatus = "idle" | "pending" | "success" | "error";
export type MutationFunction<TData, TVariables> = (
variables: TVariables,
) => Promise<TData>;
export interface MutationOptions<TData, TVariables> {
onSuccess?: (data: TData, variables: TVariables) => void;
onError?: (error: Error, variables: TVariables) => void;
onSettled?: (
data: TData | undefined,
error: Error | null,
variables: TVariables,
) => void;
}
export interface UseMutationOptions<TData, TVariables> extends MutationOptions<
TData,
TVariables
> {
mutationFn: MutationFunction<TData, TVariables>;
}
export interface UseMutationResult<TData, TVariables> {
data: TData | undefined;
error: Error | null;
/** What the last `mutate` was called with; undefined while idle. */
variables: TVariables | undefined;
isError: boolean;
isPending: boolean;
isSuccess: boolean;
status: MutationStatus;
/** Never throws; failures show up in `error`. */
mutate: (
variables: TVariables,
options?: MutationOptions<TData, TVariables>,
) => void;
mutateAsync: (
variables: TVariables,
options?: MutationOptions<TData, TVariables>,
) => Promise<TData>;
reset: () => void;
}
interface MutationState<TData, TVariables> {
data: TData | undefined;
error: Error | null;
variables: TVariables | undefined;
status: MutationStatus;
}
const IDLE_STATE: MutationState<never, never> = {
data: undefined,
error: null,
variables: undefined,
status: "idle",
};
/**
* TanStack Query v5's `useMutation`; a bare function is taken as
* `mutationFn`. Call `mutate` from an event handler and leave the rest to
* the kit: on-screen queries update optimistically, roll back if the viewer
* declines, and refetch on success.
* @example
* const complete = useMutation({ mutationFn: (status: Status) => clickup.tasks.update(task, { status }) });
* <button disabled={complete.isPending} onClick={() => complete.mutate(done)}>Mark done</button>
*/
export function useMutation<TData, TVariables = void>(
options:
| UseMutationOptions<TData, TVariables>
| MutationFunction<TData, TVariables>,
): UseMutationResult<TData, TVariables> {
const { mutationFn, ...hookCallbacks } =
typeof options === "function" ? { mutationFn: options } : options;
const [state, setState] =
useState<MutationState<TData, TVariables>>(IDLE_STATE);
const latestMutation = useRef(0);
const latestCallbacks = useRef(hookCallbacks);
latestCallbacks.current = hookCallbacks;
const mutateAsync = useCallback(
async (
variables: TVariables,
callbacks?: MutationOptions<TData, TVariables>,
): Promise<TData> => {
const mutationId = ++latestMutation.current;
setState({ data: undefined, error: null, variables, status: "pending" });
try {
const data = await mutationFn(variables);
if (mutationId === latestMutation.current) {
setState({ data, error: null, variables, status: "success" });
const listeners = [latestCallbacks.current, callbacks];
listeners.forEach((on) => on?.onSuccess?.(data, variables));
listeners.forEach((on) => on?.onSettled?.(data, null, variables));
}
return data;
} catch (cause: unknown) {
const error =
cause instanceof Error ? cause : new Error("The mutation failed.");
if (mutationId === latestMutation.current) {
setState({ data: undefined, error, variables, status: "error" });
const listeners = [latestCallbacks.current, callbacks];
listeners.forEach((on) => on?.onError?.(error, variables));
listeners.forEach((on) =>
on?.onSettled?.(undefined, error, variables),
);
}
throw error;
}
},
[mutationFn],
);
const mutate = useCallback(
(
variables: TVariables,
options?: MutationOptions<TData, TVariables>,
): void => {
void mutateAsync(variables, options).catch(() => undefined);
},
[mutateAsync],
);
const reset = useCallback(() => {
latestMutation.current += 1;
setState(IDLE_STATE);
}, []);
return {
data: state.data,
error: state.error,
variables: state.variables,
isError: state.status === "error",
isPending: state.status === "pending",
isSuccess: state.status === "success",
status: state.status,
mutate,
mutateAsync,
reset,
};
}
@@ -0,0 +1,58 @@
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
import { useLiveQuery } from "../useQuery";
export const CUSTOM_FIELD_TYPES = [
"short_text",
"text",
"email",
"url",
"phone",
"number",
"currency",
"checkbox",
"date",
"drop_down",
"labels",
"users",
"tasks",
"list_relationship",
"emoji",
"votes",
"manual_progress",
"automatic_progress",
"attachment",
"location",
"formula",
"signature",
"button",
] as const;
/** A custom field's definition. Values live on each task in `customFields`, matched by `id`. */
export interface CustomField extends Ref<"customField"> {
/** Ends at the space, folder or list the field is configured on. */
ancestors: Array<Ref<"space" | "folder" | "list">>;
type: (typeof CUSTOM_FIELD_TYPES)[number];
/** For drop-down and labels fields: the allowed values, in display order. */
options?: Array<{ name: string; color: string }>;
}
/** The fields available on a task, or on tasks in a space, folder or list. Inherited fields are included; `ancestors` shows where each is configured. */
export interface CustomFieldSelector {
parent: Ref<"task" | "space" | "folder" | "list">;
}
export interface CustomFieldsApi {
list(selector: CustomFieldSelector): Promise<CustomField[]>;
}
export function useCustomFields(
selector: CustomFieldSelector,
options: UseQueryOptions<CustomField[]> = {},
): UseQueryResult<CustomField[]> {
return useLiveQuery(
"customField",
selector,
(client) => client.customFields.list(selector),
options,
);
}
+93
View File
@@ -0,0 +1,93 @@
import * as z from "zod/v4-mini";
import { DEFAULT_COLOR } from "../components/palette";
import { toIsoOrEpoch } from "../host/dates";
import type {
ArtifactCustomFieldDefinition,
ArtifactCustomFieldWireValue,
ArtifactTaskCustomFieldDto,
} from "../host/wire";
import { OBJECT_TYPES, refOf } from "../host/wire";
import { usersOf } from "../people/read";
import type { Session } from "../session";
import type { Task } from "../tasks/tasks";
import { fromHost } from "../validate";
import type { CustomField, CustomFieldSelector } from "./customFields";
import { CustomFieldSchema } from "./schema";
type TaskCustomFieldEntry = Task["customFields"][number];
export async function readCustomFields(
s: Session,
{ parent }: CustomFieldSelector,
): Promise<CustomField[]> {
const result = await s.host.query(
"customFields",
parent.kind === "task"
? { taskIds: [parent.id] }
: { location: { id: parent.id, type: OBJECT_TYPES[parent.kind] } },
);
return fromHost(
z.array(CustomFieldSchema),
await Promise.all(
result.definitions.map((definition) => mapDefinition(s, definition)),
),
"custom fields",
);
}
async function mapDefinition(
s: Session,
wire: ArtifactCustomFieldDefinition,
): Promise<CustomField> {
return {
kind: "customField",
id: wire.fieldId,
name: wire.name,
url: "",
ancestors: await s.path(wire.location),
type: wire.type,
options: wire.options?.map((option) => ({
name: option.name,
color: option.color || DEFAULT_COLOR,
})),
};
}
/** The typed values a task carries, in the order the wire lists them. */
export function mapCustomFieldValues(
s: Session,
wire: readonly ArtifactTaskCustomFieldDto[],
): Promise<TaskCustomFieldEntry[]> {
return Promise.all(
wire.map(async (field) => ({
id: field.fieldId,
name: field.name,
type: field.type,
value: await toFieldValue(s, field.value),
})),
);
}
async function toFieldValue(
s: Session,
wire: ArtifactCustomFieldWireValue,
): Promise<TaskCustomFieldEntry["value"]> {
switch (wire.kind) {
case "boolean":
case "number":
case "text":
return wire.value;
case "date":
return toIsoOrEpoch(wire.timestamp);
case "option":
return wire.option.name;
case "labels":
return wire.options.map((option) => option.name);
case "users":
return usersOf(s, wire.users);
case "tasks":
return wire.tasks.map((task) => refOf("task", task));
case "attachments":
return wire.attachments.map((attachment) => attachment.url);
}
}
+21
View File
@@ -0,0 +1,21 @@
import * as z from "zod/v4-mini";
import { ref, refFields, schemaOf } from "../validate";
import type { CustomField, CustomFieldSelector } from "./customFields";
import { CUSTOM_FIELD_TYPES } from "./customFields";
export const CustomFieldSchema = schemaOf<CustomField>()(
z.object({
...refFields(["customField"]),
ancestors: z.array(ref(["space", "folder", "list"])),
type: z.enum(CUSTOM_FIELD_TYPES),
options: z.optional(
z.array(z.object({ name: z.string(), color: z.string() })),
),
}),
);
export const CustomFieldSelectorSchema = schemaOf<CustomFieldSelector>()(
z.strictObject({
parent: ref(["task", "space", "folder", "list"]),
}),
);
+4
View File
@@ -0,0 +1,4 @@
export {
defineEntity,
useTasksByIds,
} from "./entities/entities.ts";
+500
View File
@@ -0,0 +1,500 @@
import type {
QueryStatus,
Ref,
UseQueryOptions,
UseQueryResult,
} from "../core";
import {
clickup,
LiveError,
useCustomFields,
useMutation,
useStatuses,
useTasks,
} from "../index";
import type { CustomField } from "../customFields/customFields";
import type {
CreateTaskInput,
Status,
Task,
TaskPatch,
TaskSelector,
} from "../tasks/tasks";
import type {
Entity,
EntityClient,
EntitySchema,
FieldSpec,
ListFilter,
NativeProperty,
RecordBase,
} from "./types";
export type { Entity, EntitySchema, ListFilter, RecordBase } from "./types";
type FieldValue = Task["customFields"][number]["value"];
type Changes = Readonly<Record<string, unknown>>;
const NUMERIC_TYPES = new Set<CustomField["type"]>([
"number",
"currency",
"emoji",
"manual_progress",
"votes",
]);
const READ_NATIVE: Record<NativeProperty, (task: Task) => unknown> = {
name: (task) => task.name,
status: (task) => task.status.name,
assignees: (task) => task.assignees,
dueDate: (task) => task.dueAt,
startDate: (task) => task.startAt,
priority: (task) => task.priority,
description: (task) => task.body,
};
const nullable = (value: unknown) => (value === null ? null : String(value));
const WRITE_NATIVE: Record<
Exclude<NativeProperty, "status">,
(value: unknown) => TaskPatch
> = {
name: (value) => ({ name: String(value) }),
assignees: (value) => ({
assignees: isRefArray(value) ? (value as Task["assignees"]) : [],
}),
dueDate: (value) => ({ dueAt: nullable(value) }),
startDate: (value) => ({ startAt: nullable(value) }),
priority: (value) => ({
priority: value === null ? null : (value as Task["priority"]),
}),
description: (value) => ({ bodyMarkdown: String(value) }),
};
/** The entities whose drift check ran this session. */
const seen = new Set<string>();
/**
* Typed records over a schema the `solution` CLI provisioned: a record is one
* task in the entity's List, its custom fields named by the schema's logical
* keys. Reads are shaped like TanStack Query's `useQuery`, writes like
* `useMutation`, and every write waits for the viewer's approval.
*/
export function defineEntity<
R extends RecordBase,
I extends object,
P extends object,
S extends string,
>(schema: EntitySchema): Entity<R, I, P, S> {
const { list, key } = schema;
const listRef: Ref<"list"> = { kind: "list", ...list };
const readable = schema.fields.filter((field) => field.readable);
const listName = JSON.stringify(list.name);
const typeId =
schema.taskType === undefined ? undefined : String(schema.taskType.id);
/** Whether the task is one of this entity's records: any task when the schema names no type. */
function isRecord(task: Task): boolean {
return typeId === undefined || task.type.id === typeId;
}
function decode(task: Task): R {
const record: Record<string, unknown> = {
id: task.id,
url: task.url,
task,
};
for (const native of schema.natives) {
record[native.key] = READ_NATIVE[native.native](task);
}
const values = new Map(task.customFields.map((f) => [f.id, f.value]));
for (const spec of readable) {
const value = values.get(spec.id);
if (spec.type === "list_relationship") {
const refs = isRefArray(value) ? value : [];
record[`${spec.key}Ids`] = refs.map((ref) => ref.id);
record[`${spec.key}Refs`] = refs;
} else if (value !== undefined) {
record[spec.key] = decodeValue(spec, value);
}
}
return record as unknown as R;
}
/** The custom-field collection with the changed schema fields applied; undefined when none changed. */
function mergedFields(
current: Task["customFields"],
changes: Changes,
): Task["customFields"] | undefined {
const next = new Map(current.map((field) => [field.id, field]));
let touched = false;
for (const spec of schema.fields) {
const value = changes[spec.key];
if (value === undefined) continue;
if (spec.readOnly) {
throw new LiveError(
"invalid",
`${JSON.stringify(spec.name)} (${spec.type}) cannot be written through the live kit yet.`,
);
}
touched = true;
if (value === null) next.delete(spec.id);
else {
const { id, name, type } = spec;
next.set(id, { id, name, type, value: encodeValue(spec, value) });
}
}
return touched ? [...next.values()] : undefined;
}
async function nativePatch(changes: Changes): Promise<TaskPatch> {
const patch: TaskPatch = {};
for (const { key: property, native } of schema.natives) {
const value = changes[property];
if (value === undefined) continue;
Object.assign(
patch,
native === "status"
? { status: await statusByName(String(value)) }
: WRITE_NATIVE[native](value),
);
}
return patch;
}
async function statusByName(name: string): Promise<Status> {
return findStatus(await clickup.tasks.statuses({ parent: list.id }), name);
}
function findStatus(statuses: readonly Status[], name: string): Status {
const wanted = name.toLowerCase();
const match = statuses.find((s) => s.name.toLowerCase() === wanted);
if (match) return match;
const known = statuses.map((s) => s.name).join(", ");
throw new LiveError(
"invalid",
`${JSON.stringify(name)} is not a status of List ${listName}; statuses: ${known}.`,
);
}
/** The task with `id`, whether or not it is in this entity's List. */
async function taskById(id: string): Promise<Task> {
const [task] = await clickup.tasks.get([id]);
if (!task) {
throw new LiveError(
"invalid",
`No task with id ${id}, or it is not visible to you.`,
);
}
return task;
}
/** What ClickUp is asked for: the ids themselves, or the List with the filters it applies. */
function toSelector(
filter: ListFilter<R, S>,
statusRefs: Ref<"status">[] | undefined,
): TaskSelector | string[] {
if (filter.ids !== undefined) return [...filter.ids];
const selector: TaskSelector = { parent: list.id };
if (statusRefs) selector.status = statusRefs;
else if (filter.statusType)
selector.status = { type: [...filter.statusType] };
if (filter.limit !== undefined) selector.limit = filter.limit;
if (filter.includeClosed !== undefined)
selector.includeClosed = filter.includeClosed;
return selector;
}
/** The browser-side part of a filter; all of it when ClickUp only saw ids. */
function refine(
tasks: Task[],
filter: ListFilter<R, S>,
byIds: boolean,
): R[] {
let out = tasks.filter(isRecord).map(decode);
if (byIds && filter.status) {
const names = new Set(filter.status.map((name) => name.toLowerCase()));
out = out.filter((r) => names.has(r.task.status.name.toLowerCase()));
}
if (filter.statusType && (byIds || filter.status)) {
const types = new Set<string>(filter.statusType);
out = out.filter((r) => types.has(r.task.status.type));
}
if (filter.where) out = out.filter(filter.where);
if (filter.orderBy) {
const { field, direction } = filter.orderBy;
const sign = direction === "desc" ? -1 : 1;
out = [...out].sort((a, b) => sign * compare(a[field], b[field]));
}
if (byIds && filter.limit !== undefined) out = out.slice(0, filter.limit);
return out;
}
const client: EntityClient<R, I, P, S> = {
async list(filter = {}) {
const byIds = filter.ids !== undefined;
const statusRefs =
!byIds && filter.status?.length
? await Promise.all(filter.status.map(statusByName))
: undefined;
const selector = toSelector(filter, statusRefs);
const tasks = Array.isArray(selector)
? await clickup.tasks.get(selector)
: await clickup.tasks.list(selector);
return refine(tasks, filter, byIds);
},
async get(id) {
return decode(await taskById(id));
},
async create(input) {
const changes = input as Changes;
const {
name,
bodyMarkdown,
status,
priority,
dueAt,
startAt,
assignees,
} = await nativePatch(changes);
if (!name) {
throw new LiveError("invalid", `A new ${key} record needs a name.`);
}
const created = await clickup.tasks.create({
list: list.id,
name,
bodyMarkdown,
status,
type: typeId,
priority: priority ?? undefined,
dueAt: dueAt ?? undefined,
startAt: startAt ?? undefined,
assignees,
} satisfies CreateTaskInput);
const customFields = mergedFields([], changes);
if (customFields?.length) {
if (!created) {
throw new LiveError(
"failed",
"ClickUp created the task but did not report its id, so its fields were not written; set them in ClickUp or create the record again.",
);
}
await clickup.tasks.update(created, { customFields });
}
return created;
},
async update(id, patch) {
const changes = patch as Changes;
const task = await taskById(id);
const taskPatch = await nativePatch(changes);
const customFields = mergedFields(task.customFields, changes);
if (customFields !== undefined) taskPatch.customFields = customFields;
await clickup.tasks.update(task, taskPatch);
},
async close(id) {
const statuses = await clickup.tasks.statuses({ parent: list.id });
const closing =
statuses.find((s) => s.type === "closed") ??
[...statuses].reverse().find((s) => s.type === "done");
if (!closing) {
throw new LiveError(
"invalid",
`List ${listName} has no closing status.`,
);
}
await clickup.tasks.update(id, { status: closing });
},
};
/** Once per entity per session; a missing field is a warning, never a failed read. */
function useDriftCheck(enabled: boolean): void {
const pending = enabled && readable.length > 0 && !seen.has(`drift:${key}`);
const fields = useCustomFields({ parent: listRef }, { enabled: pending });
if (!pending || fields.data === undefined) return;
seen.add(`drift:${key}`);
const present = new Set(fields.data.map((field) => field.id));
const missing = readable.filter((field) => !present.has(field.id));
if (missing.length > 0) {
const named = missing.map((f) => `${f.key} (${f.id})`).join(", ");
console.warn(
`${key}: ${named} ${missing.length === 1 ? "is" : "are"} not on List ${listName} as the viewer sees it; the record leaves them unset. Run solution apply (or solution codegen) to regenerate this directory if the schema changed.`,
);
}
}
function useList(
filter: ListFilter<R, S> = {},
options: UseQueryOptions<Task[]> = {},
): UseQueryResult<R[]> {
const enabled = options.enabled !== false;
const byIds = filter.ids !== undefined;
const wantsStatuses = !byIds && (filter.status?.length ?? 0) > 0;
const statuses = useStatuses(
{ parent: list.id },
{ enabled: enabled && wantsStatuses },
);
let statusRefs: Ref<"status">[] | undefined;
let statusError: LiveError | null = null;
if (wantsStatuses && statuses.data) {
try {
statusRefs = filter.status!.map((name) =>
findStatus(statuses.data!, name),
);
} catch (error) {
statusError = error as LiveError;
}
}
const tasks = useTasks(toSelector(filter, statusRefs), {
...options,
enabled: enabled && (!wantsStatuses || statusRefs !== undefined),
});
useDriftCheck(enabled);
const data = tasks.data && refine(tasks.data, filter, byIds);
const error = statusError ?? statuses.error ?? tasks.error;
const status: QueryStatus = error
? "error"
: data !== undefined
? "success"
: "pending";
const isPending = status === "pending";
const isFetching = statuses.isFetching || tasks.isFetching;
return {
data,
error,
status,
isPending,
isLoading: isPending && isFetching,
isFetching,
isRefetching: isFetching && !isPending,
isPlaceholderData: tasks.isPlaceholderData,
isError: status === "error",
isSuccess: status === "success",
dataUpdatedAt: tasks.dataUpdatedAt,
refetch: tasks.refetch,
};
}
function useOne(
id: string | undefined,
options: UseQueryOptions<Task[]> = {},
): UseQueryResult<R | undefined> {
return mapResult(
useTasksByIds(id === undefined ? [] : [id], options),
(tasks) => (tasks[0] ? decode(tasks[0]) : undefined),
);
}
return {
schema,
decode,
client,
useList,
useOne,
useMany: (ids, options = {}) =>
mapResult(useTasksByIds(ids, options), (tasks) => tasks.map(decode)),
useCreate: () => useMutation((input: I) => client.create(input)),
useUpdate: () =>
useMutation((variables: { id: string; patch: P }) =>
client.update(variables.id, variables.patch),
),
useClose: () => useMutation((id: string) => client.close(id)),
};
}
/**
* Tasks by id, in the order given. An empty list is a settled `[]` with no
* loading state, so a relationship hook fed an unlinked record renders its
* empty state at once.
*/
export function useTasksByIds(
ids: readonly string[],
options: UseQueryOptions<Task[]> = {},
): UseQueryResult<Task[]> {
const result = useTasks([...ids], {
...options,
enabled: options.enabled !== false && ids.length > 0,
});
return ids.length > 0
? result
: {
...result,
data: [],
error: null,
status: "success",
isPending: false,
isLoading: false,
isRefetching: false,
isError: false,
isSuccess: true,
};
}
function mapResult<T, U>(
result: UseQueryResult<T>,
fn: (data: T) => U,
): UseQueryResult<U> {
return { ...result, data: result.data && fn(result.data) };
}
/** A ClickUp value as the record expects it: option labels become logical keys. */
function decodeValue(spec: FieldSpec, value: FieldValue): unknown {
if (spec.type === "drop_down") {
return typeof value === "string" ? optionKey(spec, value) : value;
}
if (spec.type === "labels") {
return Array.isArray(value)
? value.map((e) => optionKey(spec, typeof e === "string" ? e : e.name))
: [optionKey(spec, String(value))];
}
if (spec.type === "checkbox") return value === true || value === "true";
if (NUMERIC_TYPES.has(spec.type)) return Number(value);
if (spec.type === "users") return isRefArray(value) ? value : [];
if (spec.type === "attachment") {
return Array.isArray(value) ? value.filter(isString) : [];
}
return value;
}
/** A record value as ClickUp takes it: option keys back to labels. */
function encodeValue(spec: FieldSpec, value: unknown): FieldValue {
if (spec.type === "drop_down") return optionLabel(spec, String(value));
if (spec.type === "labels") {
return (Array.isArray(value) ? value : [value]).map((k) =>
optionLabel(spec, String(k)),
);
}
if (spec.type === "checkbox") return Boolean(value);
if (NUMERIC_TYPES.has(spec.type)) return Number(value);
return String(value);
}
/** A label the schema never named passes through as its own key. */
function optionKey(spec: FieldSpec, label: string): string {
const entry = Object.entries(spec.options ?? {}).find(
([, option]) => option.label === label,
);
return entry ? entry[0] : label;
}
function optionLabel(spec: FieldSpec, key: string): string {
return spec.options?.[key]?.label ?? key;
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isRefArray(value: unknown): value is Ref[] {
return (
Array.isArray(value) &&
value.every((e) => typeof e?.id === "string" && typeof e?.kind === "string")
);
}
/** Numbers by value, everything else by text; unset values sort last. */
function compare(a: unknown, b: unknown): number {
if (a === b) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b));
}
+246
View File
@@ -0,0 +1,246 @@
import type {
Delta,
LiveErrorCode,
Ref,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "../core";
import type { CustomField } from "../customFields/customFields";
import type {
CreateTaskInput,
Status,
StatusSelector,
Task,
TaskPatch,
TaskSelector,
} from "../tasks/tasks";
/** A stand-in for the kit barrel that records every call and answers hooks synchronously, for testing the entities engine without a host or a renderer. */
export interface FakeKitState {
tasks: Map<string, Task>;
statuses: Status[];
customFields: CustomField[];
calls: unknown[][];
nextId: number;
/** "silent" makes the next create succeed without reporting the task. */
created?: "silent";
}
class LiveError extends Error {
readonly code: LiveErrorCode;
readonly retryable: boolean;
readonly reason: string | undefined;
constructor(
code: LiveErrorCode,
message: string,
options: { retryable?: boolean; reason?: string } = {},
) {
super(message);
this.name = "LiveError";
this.code = code;
this.retryable = options.retryable ?? false;
this.reason = options.reason;
}
}
function idOf(target: Ref | string): string {
return typeof target === "string" ? target : target.id;
}
function taskUrl(id: string): string {
return `https://app.clickup.com/t/${id}`;
}
function paragraph(text: string): Delta {
return { ops: [{ insert: `${text}\n` }] };
}
function result<T>(data: T | undefined, enabled = true): UseQueryResult<T> {
const isPending = data === undefined;
const isFetching = isPending && enabled;
return {
data,
error: null,
status: isPending ? "pending" : "success",
isPending,
isLoading: isPending && isFetching,
isFetching,
isRefetching: false,
isPlaceholderData: false,
isError: false,
isSuccess: !isPending,
dataUpdatedAt: isPending ? 0 : 1,
refetch: () => Promise.resolve(),
};
}
export function createFakeKit() {
const state: FakeKitState = {
tasks: new Map(),
statuses: [],
customFields: [],
calls: [],
nextId: 100,
};
function getTasks(targets: Array<Ref<"task"> | string>): Task[] {
return targets.flatMap((target) => {
const task = state.tasks.get(idOf(target));
return task ? [task] : [];
});
}
function listTasks(selector: TaskSelector): Task[] {
let tasks = [...state.tasks.values()].filter(
(task) =>
task.ancestors[task.ancestors.length - 1]!.id ===
idOf(selector.parent!),
);
if (Array.isArray(selector.status)) {
const ids = new Set(selector.status.map((status) => status.id));
tasks = tasks.filter((task) => ids.has(task.status.id));
} else if (selector.status) {
const types = new Set<string>(selector.status.type);
tasks = tasks.filter((task) => types.has(task.status.type));
}
if (!selector.includeClosed) {
tasks = tasks.filter((task) => task.status.type !== "closed");
}
return tasks.slice(0, selector.limit ?? 100);
}
const clickup = {
tasks: {
async list(selector: TaskSelector): Promise<Task[]> {
state.calls.push(["tasks.list", selector]);
return listTasks(selector);
},
async get(targets: Array<Ref<"task"> | string>): Promise<Task[]> {
state.calls.push(["tasks.get", targets.map(idOf)]);
return getTasks(targets);
},
async update(
target: Ref<"task"> | string,
patch: TaskPatch,
): Promise<void> {
state.calls.push(["tasks.update", idOf(target), patch]);
const task = state.tasks.get(idOf(target))!;
if (patch.name !== undefined) task.name = patch.name;
if (patch.status !== undefined) task.status = patch.status as Status;
if (patch.dueAt !== undefined) task.dueAt = patch.dueAt ?? undefined;
if (patch.bodyMarkdown !== undefined) {
task.body = paragraph(patch.bodyMarkdown);
}
if (patch.customFields !== undefined) {
task.customFields = patch.customFields;
}
},
async create(input: CreateTaskInput): Promise<Ref<"task"> | undefined> {
state.calls.push(["tasks.create", input]);
const id = String(state.nextId++);
const typeId = input.type === undefined ? "0" : idOf(input.type);
state.tasks.set(id, {
kind: "task",
id,
name: input.name,
url: taskUrl(id),
ancestors: [
{ kind: "list", id: idOf(input.list), name: "List", url: "" },
],
otherLists: [],
private: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
dueAt: input.dueAt,
status: (input.status ?? state.statuses[0]) as Status,
type: { kind: "taskType", id: typeId, name: "", url: "" },
tags: [],
customFields: [],
body: paragraph(input.bodyMarkdown ?? ""),
creator: { kind: "user", id: "u1", name: "Creator", url: "" },
assignees: input.assignees ?? [],
watchers: [],
checklists: [],
});
return state.created === "silent"
? undefined
: { kind: "task", id, name: input.name, url: taskUrl(id) };
},
async statuses(selector: StatusSelector): Promise<Status[]> {
state.calls.push(["tasks.statuses", selector]);
return state.statuses;
},
},
customFields: {
async list(): Promise<CustomField[]> {
return state.customFields;
},
},
};
function useTasks(
selector: TaskSelector | Array<Ref<"task"> | string>,
options: UseQueryOptions = {},
): UseQueryResult<Task[]> {
state.calls.push(["useTasks", selector, options]);
if (options.enabled === false) {
return result<Task[]>(undefined, false);
}
return result(
Array.isArray(selector) ? getTasks(selector) : listTasks(selector),
);
}
function useStatuses(
selector: StatusSelector,
options: UseQueryOptions = {},
): UseQueryResult<Status[]> {
state.calls.push(["useStatuses", selector, options]);
return options.enabled === false
? result<Status[]>(undefined, false)
: result(state.statuses);
}
function useCustomFields(
selector: unknown,
options: UseQueryOptions = {},
): UseQueryResult<CustomField[]> {
state.calls.push(["useCustomFields", selector, options]);
return options.enabled === false
? result<CustomField[]>(undefined, false)
: result(state.customFields);
}
function useMutation<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<TData>,
): UseMutationResult<TData, TVariables> {
return {
data: undefined,
error: null,
variables: undefined,
isError: false,
isPending: false,
isSuccess: false,
status: "idle",
mutate(variables: TVariables) {
mutationFn(variables).catch(() => undefined);
},
mutateAsync: mutationFn,
reset() {},
} as UseMutationResult<TData, TVariables>;
}
return {
LiveError,
clickup,
useTasks,
useStatuses,
useCustomFields,
useMutation,
__state: state,
};
}
export type FakeKit = ReturnType<typeof createFakeKit>;
+111
View File
@@ -0,0 +1,111 @@
import type {
Ref,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "../core";
import type { CustomField } from "../customFields/customFields";
import type { Status, Task } from "../tasks/tasks";
/** A property every task has, mapped to a record property by the schema. */
export type NativeProperty =
| "name"
| "status"
| "assignees"
| "dueDate"
| "startDate"
| "priority"
| "description";
export interface OptionSpec {
id: string;
/** The option's name in ClickUp; what the kit reads and writes. */
label: string;
}
export interface FieldSpec {
/** The record property; a relationship becomes <key>Ids and <key>Refs. */
key: string;
/** The ClickUp custom field id. */
id: string;
name: string;
type: CustomField["type"];
required: boolean;
/** False keeps a type the kit cannot read off the record and out of the drift check. */
readable: boolean;
/** The kit cannot write this type yet; writes are rejected. */
readOnly: boolean;
/** Drop-down and labels only: logical option key to ClickUp option. */
options?: Readonly<Record<string, OptionSpec>>;
targetEntity?: string;
targetListId?: string;
}
/** One entity's frozen ids, as _schema.ts exports them. */
export interface EntitySchema {
key: string;
list: { id: string; name: string; url: string };
taskType?: { id: number; name: string };
natives: ReadonlyArray<{ key: string; native: NativeProperty }>;
fields: readonly FieldSpec[];
}
export interface RecordBase {
id: string;
/** Link to the task in ClickUp; safe to render for the viewer. */
url: string;
/** The underlying clickup-kit Task, for anything the schema does not model. */
task: Task;
}
/**
* status, statusType, limit and includeClosed are applied by ClickUp; the
* entity's task type, where and orderBy run in the browser on the decoded
* records. With ids, ClickUp returns exactly those tasks and every filter
* runs in the browser.
*/
export interface ListFilter<R, S extends string> {
/** Status names as shown in ClickUp, case-insensitive. */
status?: readonly S[];
statusType?: ReadonlyArray<Status["type"]>;
ids?: readonly string[];
limit?: number;
/** ClickUp hides tasks in the closing status unless this is true. */
includeClosed?: boolean;
where?: (record: R) => boolean;
/** Unset values sort last. */
orderBy?: { field: keyof R; direction?: "asc" | "desc" };
}
export interface EntityClient<R, I, P, S extends string> {
list(filter?: ListFilter<R, S>): Promise<R[]>;
get(id: string): Promise<R>;
/** Resolves with the new task's ref when the host reports it. */
create(input: I): Promise<Ref<"task"> | undefined>;
/** undefined leaves a property alone; null clears one that is optional. */
update(id: string, patch: P): Promise<void>;
/** Moves the task to the List's closing status. There is no delete. */
close(id: string): Promise<void>;
}
export interface Entity<R extends RecordBase, I, P, S extends string> {
schema: EntitySchema;
decode(task: Task): R;
client: EntityClient<R, I, P, S>;
useList(
filter?: ListFilter<R, S>,
options?: UseQueryOptions<Task[]>,
): UseQueryResult<R[]>;
/** data is undefined while loading and when no task has that id; isSuccess tells them apart. */
useOne(
id: string | undefined,
options?: UseQueryOptions<Task[]>,
): UseQueryResult<R | undefined>;
useMany(
ids: readonly string[],
options?: UseQueryOptions<Task[]>,
): UseQueryResult<R[]>;
useCreate(): UseMutationResult<Ref<"task"> | undefined, I>;
useUpdate(): UseMutationResult<void, { id: string; patch: P }>;
useClose(): UseMutationResult<void, string>;
}
+84
View File
@@ -0,0 +1,84 @@
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
import { listOrGet, useLiveQuery } from "../useQuery";
export interface Space extends Ref<"space"> {
private: boolean;
}
export interface Folder extends Ref<"folder"> {
ancestors: Array<Ref<"space" | "folder">>;
private: boolean;
}
export interface List extends Ref<"list"> {
ancestors: Array<Ref<"space" | "folder">>;
private: boolean;
}
/** Needs `parent` or `ancestor`. */
export interface FolderSelector {
parent?: Ref<"space" | "folder">;
ancestor?: Ref<"space"> | string;
/** Defaults to 100, at most 500. */
limit?: number;
}
/** Needs `parent` or `ancestor`. */
export interface ListSelector {
parent?: Ref<"space" | "folder">;
ancestor?: Ref<"space"> | string;
/** Defaults to 100, at most 500. */
limit?: number;
}
export interface SpacesApi {
list(): Promise<Space[]>;
get(targets: Array<Ref<"space"> | string>): Promise<Space[]>;
}
export interface FoldersApi {
list(selector: FolderSelector): Promise<Folder[]>;
get(targets: Array<Ref<"folder"> | string>): Promise<Folder[]>;
}
export interface ListsApi {
list(selector: ListSelector): Promise<List[]>;
get(targets: Array<Ref<"list"> | string>): Promise<List[]>;
}
/** Every space, or exactly the given ones. */
export function useSpaces(
targets?: Array<Ref<"space"> | string>,
options: UseQueryOptions<Space[]> = {},
): UseQueryResult<Space[]> {
return useLiveQuery(
"space",
targets ?? {},
(client) => listOrGet(client.spaces, targets ?? {}),
options,
);
}
export function useFolders(
selector: FolderSelector | Array<Ref<"folder"> | string>,
options: UseQueryOptions<Folder[]> = {},
): UseQueryResult<Folder[]> {
return useLiveQuery(
"folder",
selector,
(client) => listOrGet(client.folders, selector),
options,
);
}
export function useLists(
selector: ListSelector | Array<Ref<"list"> | string>,
options: UseQueryOptions<List[]> = {},
): UseQueryResult<List[]> {
return useLiveQuery(
"list",
selector,
(client) => listOrGet(client.lists, selector),
options,
);
}
+259
View File
@@ -0,0 +1,259 @@
import * as z from "zod/v4-mini";
import type { Ref } from "../core";
import { LiveError } from "../core";
import type { LiveBridge } from "../host/bridge";
import { chunk, pages } from "../host/paging";
import type {
ArtifactFolderDto,
ArtifactHierarchyQuery,
ArtifactHierarchyResult,
ArtifactListDto,
} from "../host/wire";
import {
ARTIFACT_HIERARCHY_MAX_IDS,
ARTIFACT_QUERY_MAX_LIMIT,
refOf,
} from "../host/wire";
import { memo, memoBy } from "../memo";
import { byIds, clampLimit, selectLoaded } from "../select";
import type { Session } from "../session";
import { scopeOf, treeOf } from "../session";
import { fromHost } from "../validate";
import type {
Folder,
FolderSelector,
List,
ListSelector,
Space,
} from "./hierarchy";
import { FolderSchema, ListSchema, SpaceSchema } from "./schema";
/** The `hierarchy` source, mapped: spaces, folders and lists. */
export interface Hierarchy {
spaces: Space[];
folders: Folder[];
lists: List[];
}
export const HIERARCHY_KINDS = ["space", "folder", "list"] as const;
export type HierarchyKind = "space" | "folder" | "list";
interface Lookup {
kind: HierarchyKind;
ids: string[];
}
/** The hierarchy by scope, each scope read once per session. */
export interface HierarchyReads {
/** Every space, without their folders and lists. */
spaces(): Promise<Hierarchy>;
/** One space's folders and lists. */
tree(spaceId: string): Promise<Hierarchy>;
/** Exactly these `kind` assets, with the folders and spaces they sit in. */
lookup(kind: HierarchyKind, ids: string[]): Promise<Hierarchy>;
/** Forgets everything read, so the next reads reach the host again. */
reset(): void;
}
export function createHierarchyReads(host: LiveBridge): HierarchyReads {
const spaces = memo(async () =>
mapHierarchy(await browse(host, { entities: "spaces" })),
);
const tree = memoBy(async (spaceId: string) =>
mapHierarchy(await browse(host, { spaceId })),
);
const lookups = memoBy(
async (target: Lookup) => mapHierarchy(await lookup(host, target)),
({ kind, ids }) => `${kind}:${[...ids].sort().join(",")}`,
);
return {
spaces,
tree,
lookup: (kind, ids) => lookups({ kind, ids }),
reset: () => {
spaces.reset();
tree.reset();
lookups.reset();
},
};
}
type BrowseInput = Omit<
Exclude<ArtifactHierarchyQuery, { ids: unknown }>,
"cursor" | "limit"
>;
async function browse(
host: LiveBridge,
input: BrowseInput,
): Promise<ArtifactHierarchyResult> {
const wire: ArtifactHierarchyResult = { spaces: [], folders: [], lists: [] };
for await (const page of pages((cursor) =>
host.query("hierarchy", {
...input,
limit: ARTIFACT_QUERY_MAX_LIMIT,
cursor,
}),
)) {
wire.spaces.push(...page.spaces);
wire.folders.push(...page.folders);
wire.lists.push(...page.lists);
}
return wire;
}
async function lookup(
host: LiveBridge,
{ kind, ids }: Lookup,
): Promise<ArtifactHierarchyResult> {
const results = await Promise.all(
chunk(ids, ARTIFACT_HIERARCHY_MAX_IDS).map((batch) =>
host.query("hierarchy", {
ids: batch.map((id) => ({ kind, id })),
includeParents: true,
}),
),
);
return {
spaces: dedupe(results.flatMap((result) => result.spaces)),
folders: dedupe(results.flatMap((result) => result.folders)),
lists: dedupe(results.flatMap((result) => result.lists)),
};
}
function dedupe<T extends { id: string }>(assets: T[]): T[] {
return [...new Map(assets.map((asset) => [asset.id, asset])).values()];
}
function mapHierarchy(wire: ArtifactHierarchyResult): Hierarchy {
const spacesById = new Map(wire.spaces.map((space) => [space.id, space]));
const foldersById = new Map(
wire.folders.map((folder) => [folder.id, folder]),
);
/** The folders enclosing a folder or list, innermost first, starting at `folderId`. */
const foldersAbove = (
child: ArtifactFolderDto | ArtifactListDto,
folderId: string | undefined,
): ArtifactFolderDto[] => {
const folders: ArtifactFolderDto[] = [];
let id = folderId;
while (id !== undefined) {
const folder = listed(foldersById, id, child);
if (folders.includes(folder)) {
throw new LiveError(
"failed",
`The host returned folders nested in a cycle above "${child.name}" (${child.id}).`,
);
}
folders.push(folder);
id = folder.parentFolderId;
}
return folders;
};
/** Root first: the space, then the enclosing folders from the outside in. */
const ancestorsOf = (
child: ArtifactFolderDto | ArtifactListDto,
folderId: string | undefined,
): Array<Ref<"space" | "folder">> => [
refOf("space", listed(spacesById, child.spaceId, child)),
...foldersAbove(child, folderId)
.reverse()
.map((folder) => refOf("folder", folder)),
];
const spaces = wire.spaces.map(
(space): Space => ({ ...refOf("space", space), private: space.private }),
);
const folders = wire.folders.map(
(folder): Folder => ({
...refOf("folder", folder),
ancestors: ancestorsOf(folder, folder.parentFolderId),
private: folder.private,
}),
);
const lists = wire.lists.map(
(list): List => ({
...refOf("list", list),
ancestors: ancestorsOf(list, list.folderId),
private: list.private,
}),
);
return {
spaces: fromHost(z.array(SpaceSchema), spaces, "spaces"),
folders: fromHost(z.array(FolderSchema), folders, "folders"),
lists: fromHost(z.array(ListSchema), lists, "lists"),
};
}
/** The parent the same hierarchy result lists; the host is broken if it does not. */
function listed<T>(
parents: Map<string, T>,
id: string,
child: { id: string; name: string },
): T {
const parent = parents.get(id);
if (!parent) {
throw new LiveError(
"failed",
`The host returned "${child.name}" (${child.id}) under an unlisted parent ${id}.`,
);
}
return parent;
}
/** The tree of the space a folder or list selector scopes to; a bare `ancestor` is a space id. */
function scopedTree(
s: Session,
selector: FolderSelector | ListSelector,
): Promise<Hierarchy> {
const scope = scopeOf(selector);
return typeof scope === "string" ? s.hierarchy.tree(scope) : treeOf(s, scope);
}
/** A folder or list selector as `selectLoaded` takes it, with a bare `ancestor` read as a space. */
function loadedSelector(selector: FolderSelector | ListSelector) {
const { ancestor } = selector;
return {
...selector,
ancestor:
typeof ancestor === "string"
? { kind: "space" as const, id: ancestor }
: ancestor,
limit: clampLimit(selector.limit),
};
}
export async function readSpaces(s: Session): Promise<Space[]> {
return (await s.hierarchy.spaces()).spaces;
}
export async function getSpaces(s: Session, ids: string[]): Promise<Space[]> {
return byIds((await s.hierarchy.lookup("space", ids)).spaces, ids);
}
export async function readFolders(
s: Session,
selector: FolderSelector,
): Promise<Folder[]> {
const { folders } = await scopedTree(s, selector);
return selectLoaded(folders, loadedSelector(selector));
}
export async function getFolders(s: Session, ids: string[]): Promise<Folder[]> {
return byIds((await s.hierarchy.lookup("folder", ids)).folders, ids);
}
export async function readLists(
s: Session,
selector: ListSelector,
): Promise<List[]> {
const { lists } = await scopedTree(s, selector);
return selectLoaded(lists, loadedSelector(selector));
}
export async function getLists(s: Session, ids: string[]): Promise<List[]> {
return byIds((await s.hierarchy.lookup("list", ids)).lists, ids);
}
+56
View File
@@ -0,0 +1,56 @@
import * as z from "zod/v4-mini";
import {
ref,
refFields,
refOrId,
schemaOf,
scoped,
selection,
} from "../validate";
import type {
Folder,
FolderSelector,
List,
ListSelector,
Space,
} from "./hierarchy";
export const SpaceSchema = schemaOf<Space>()(
z.object({ ...refFields(["space"]), private: z.boolean() }),
);
export const FolderSchema = schemaOf<Folder>()(
z.object({
...refFields(["folder"]),
ancestors: z.array(ref(["space", "folder"])),
private: z.boolean(),
}),
);
export const ListSchema = schemaOf<List>()(
z.object({
...refFields(["list"]),
ancestors: z.array(ref(["space", "folder"])),
private: z.boolean(),
}),
);
export const FolderSelectorSchema = schemaOf<FolderSelector>()(
z
.strictObject({
parent: z.optional(ref(["space", "folder"])),
ancestor: z.optional(refOrId("space")),
...selection,
})
.check(scoped),
);
export const ListSelectorSchema = schemaOf<ListSelector>()(
z
.strictObject({
parent: z.optional(ref(["space", "folder"])),
ancestor: z.optional(refOrId("space")),
...selection,
})
.check(scoped),
);
+89
View File
@@ -0,0 +1,89 @@
import { LiveError } from "../core";
import { readError } from "./errors";
import type {
ArtifactHostBridgeApi,
ArtifactMemberDto,
ArtifactQuerySourceInputs,
ArtifactQuerySourceResults,
DataQueryResult,
EmbedDataQuery,
EmbedSearchRequest,
KitQuerySource,
SearchUpdate,
UserActionOutcome,
} from "./wire";
/** The calls the kit makes to its host; every rejection is a `LiveError`. */
export interface LiveBridge {
query<S extends KitQuerySource>(
source: S,
input: ArtifactQuerySourceInputs[S],
): Promise<ArtifactQuerySourceResults[S]>;
queryData(query: EmbedDataQuery): Promise<DataQueryResult>;
requestUserAction(
actionType: string,
params: Record<string, string>,
): Promise<UserActionOutcome>;
subscribeSearch(
search: EmbedSearchRequest,
onUpdate: (update: SearchUpdate) => void,
): () => void;
/** The current viewer, known to the host without a grant; throws `forbidden` on a host that predates it. */
viewer(): ArtifactMemberDto;
}
type BridgeWindow = Window & { clickup?: ArtifactHostBridgeApi };
const rethrowAsLiveError = (reason: unknown): never => {
throw readError(reason);
};
/** The bridge the ClickUp embed shell installs on `window.clickup`. */
export function windowBridge(): LiveBridge {
const member = <K extends keyof ArtifactHostBridgeApi>(
name: K,
): NonNullable<ArtifactHostBridgeApi[K]> => {
const clickup = (window as BridgeWindow).clickup;
if (!clickup) {
throw new LiveError(
"forbidden",
"This artifact is not running inside a ClickUp host.",
);
}
const value = clickup[name];
if (!value) {
throw new LiveError(
"forbidden",
`The host does not support ${name}; update the ClickUp app.`,
);
}
return value;
};
return {
async query(source, input) {
const result = await member("query")(source, input).catch(
rethrowAsLiveError,
);
return result as ArtifactQuerySourceResults[typeof source];
},
async queryData(query) {
return member("queryData")(query).catch(rethrowAsLiveError);
},
async requestUserAction(actionType, params) {
return member("requestUserAction")(actionType, params).catch(
rethrowAsLiveError,
);
},
subscribeSearch(search, onUpdate) {
try {
return member("subscribeSearch")(search, onUpdate);
} catch (reason) {
return rethrowAsLiveError(reason);
}
},
viewer() {
return member("getViewer")();
},
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { IsoDate } from "../core";
const UNIX_EPOCH: IsoDate = new Date(0).toISOString();
/** Wire dates are epoch-milliseconds strings or ISO strings; both become ISO. */
export function toIso(
value: string | number | null | undefined,
): IsoDate | undefined {
if (value === null || value === undefined || value === "") {
return undefined;
}
const numeric = typeof value === "number" ? value : Number(value);
const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
export function toIsoOrEpoch(
value: string | number | null | undefined,
): IsoDate {
return toIso(value) ?? UNIX_EPOCH;
}
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** ISO to epoch milliseconds; a date-only value is noon of that day in the viewer's timezone. */
export function toEpochMs(iso: IsoDate): number {
const ms = new Date(DATE_ONLY.test(iso) ? `${iso}T12:00:00` : iso).getTime();
if (Number.isNaN(ms)) {
throw new RangeError(`Not an ISO 8601 date: ${iso}`);
}
return ms;
}
/** ISO to the minute-aligned epoch-milliseconds string the action verbs take. */
export function toEpochMinuteString(iso: IsoDate): string {
const ms = toEpochMs(iso);
return String(ms - (ms % 60_000));
}
+123
View File
@@ -0,0 +1,123 @@
import { LiveError } from "../core";
import type { UserActionOutcome } from "./wire";
import {
ARTIFACT_ACTION_FAILURE_REASONS as FAILURE,
ARTIFACT_READ_ERROR_CODES as READ,
ARTIFACT_USER_ACTION_DENY_REASONS as DENY,
NOT_DECLARED,
NOT_ENABLED,
} from "./wire";
const FORBIDDEN_READS: ReadonlySet<string> = new Set([
READ.capabilityRevoked,
READ.dataSourceRevoked,
READ.documentReplaced,
READ.missingOrigin,
READ.notGranted,
READ.sourceUnavailable,
NOT_DECLARED,
NOT_ENABLED,
]);
const INVALID_READS: ReadonlySet<string> = new Set([
READ.invalidLocation,
READ.invalidQuery,
READ.invalidSearch,
]);
const RETRYABLE_READS: ReadonlySet<string> = new Set([
READ.queryFailed,
READ.rateLimited,
READ.searchFailed,
READ.timeout,
READ.tooManySubscriptions,
]);
/** Maps a raw bridge rejection to a `LiveError`. */
export function readError(reason: unknown): LiveError {
if (reason instanceof LiveError) {
return reason;
}
const raw =
reason instanceof Error
? reason.message
: typeof reason === "string" && reason
? reason
: "failed";
if (FORBIDDEN_READS.has(raw) || raw.includes("is not granted")) {
return new LiveError(
"forbidden",
`The host does not allow this read (${raw}).`,
{ reason: raw },
);
}
if (raw.includes("is unavailable") || raw.includes("not connected")) {
return new LiveError(
"forbidden",
"This artifact is not connected to a ClickUp host.",
{ reason: raw },
);
}
if (INVALID_READS.has(raw)) {
return new LiveError("invalid", `The host rejected the query (${raw}).`, {
reason: raw,
});
}
return new LiveError("failed", `The read failed (${raw}).`, {
reason: raw,
retryable: RETRYABLE_READS.has(raw),
});
}
const FORBIDDEN_DENIALS: ReadonlySet<string> = new Set([
DENY.grantRevoked,
DENY.notGranted,
DENY.unknownAction,
NOT_DECLARED,
NOT_ENABLED,
]);
const RETRYABLE_FAILURES: ReadonlySet<string> = new Set([
FAILURE.stateChanged,
FAILURE.executionFailed,
]);
/** Maps a non-executed action outcome to a `LiveError`. */
export function actionError(
actionType: string,
outcome: UserActionOutcome,
): LiveError {
const reason = outcome.reason;
const detail = reason ? ` (${reason})` : "";
if (outcome.status === "denied") {
if (reason === DENY.userDenied) {
return new LiveError(
"denied",
`The user did not approve ${actionType}.`,
{ reason },
);
}
if (reason && FORBIDDEN_DENIALS.has(reason)) {
return new LiveError(
"forbidden",
`The host does not allow ${actionType}${detail}.`,
{ reason },
);
}
if (reason === DENY.invalidParams) {
return new LiveError(
"invalid",
`The host rejected the parameters of ${actionType}.`,
{ reason },
);
}
return new LiveError("failed", `${actionType} was not accepted${detail}.`, {
reason,
retryable: true,
});
}
return new LiveError("failed", `${actionType} failed${detail}.`, {
reason,
retryable: reason !== undefined && RETRYABLE_FAILURES.has(reason),
});
}
+19
View File
@@ -0,0 +1,19 @@
/** Walks a cursor-paged source to its last page; `break` out of the loop to stop early. */
export async function* pages<P extends { nextCursor?: string }>(
fetchPage: (cursor: string | undefined) => Promise<P>,
): AsyncGenerator<P> {
let cursor: string | undefined;
do {
const page = await fetchPage(cursor);
yield page;
cursor = page.nextCursor;
} while (cursor);
}
export function chunk<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
+46
View File
@@ -0,0 +1,46 @@
import type { Ref } from "../core";
import { LiveError } from "../core";
import type { LiveBridge } from "./bridge";
import { actionError } from "./errors";
import type { UserActionOutcome } from "./wire";
/** One host action: its type and the string parameters the host takes. */
export type WireCall = [actionType: string, params: Record<string, string>];
/** Runs one action through the user's approval; anything but `executed` throws. */
export async function perform(
host: LiveBridge,
actionType: string,
params: Record<string, string>,
): Promise<UserActionOutcome> {
const outcome = await host.requestUserAction(actionType, params);
if (outcome.status !== "executed") {
throw actionError(actionType, outcome);
}
return outcome;
}
export async function performAll(
host: LiveBridge,
calls: WireCall[],
): Promise<void> {
for (const [actionType, params] of calls) {
await perform(host, actionType, params);
}
}
/** The ref of what a create action made; `name` is what the caller asked for unless the host says otherwise. */
export function createdRef<K extends Ref["kind"]>(
kind: K,
outcome: UserActionOutcome,
name: string,
): Ref<K> {
const { id, url, name: reported } = outcome.result ?? {};
if (!id || url === undefined) {
throw new LiveError(
"failed",
`The host did not report the ${kind} it created.`,
);
}
return { kind, id, name: reported ?? name, url };
}
+28
View File
@@ -0,0 +1,28 @@
import * as z from "zod/v4-mini";
import type { Delta } from "../core";
import { fromHost } from "../validate";
import type { LiveBridge } from "./bridge";
/** `text` as a Delta of one paragraph. */
export function paragraph(text: string): Delta {
return { ops: [{ insert: `${text}\n` }] };
}
const HtmlSchema = z.object({ html: z.string() });
/** `delta` as the HTML ClickUp shows for it, rendered by the host. */
export async function toHtml(host: LiveBridge, delta: Delta): Promise<string> {
const result = await host.query("quillHtml", { content: delta });
return fromHost(HtmlSchema, result, "html").html;
}
const UNIFIED_EMOJI = /^[0-9a-f]{2,6}(-[0-9a-f]{2,6})*$/i;
/** Hosts report a standard emoji as its unified code (`1F44D`, `1F44D-1F3FB`); a custom emoji keeps its name. */
export function toEmoji(code: string): string {
return UNIFIED_EMOJI.test(code)
? String.fromCodePoint(
...code.split("-").map((part) => Number.parseInt(part, 16)),
)
: code;
}
+114
View File
@@ -0,0 +1,114 @@
import type {
ArtifactCommentsQuery,
ArtifactCustomFieldsQuery,
ArtifactHierarchyQuery,
ArtifactInboxQuery,
ArtifactMembersQuery,
ArtifactQuillDelta,
ArtifactRemindersQuery,
ArtifactStatusesQuery,
ArtifactTasksQuery,
ArtifactTaskTypesQuery,
ArtifactWorkspaceQuery,
EMBED_DATA_QUERY_SORT_FIELDS,
EmbedDataQueryFilter,
EmbedDataQueryLocationType,
EmbedSearchOrderBy,
EmbedSearchOrderDirection,
} from "@time-loop/artifacts-protocol";
import type { AssetKind, Ref } from "../core";
import type { Task } from "../tasks/tasks";
export * from "@time-loop/artifacts-protocol";
/** The host's refusal of a call the artifact's build did not declare in its capabilities manifest. */
export const NOT_DECLARED = "not-declared";
/** The host's refusal of a call whose feature is off for the workspace. */
export const NOT_ENABLED = "not-enabled";
export const ARTIFACT_QUERY_MAX_TASK_IDS = 50;
export const ARTIFACT_COMMENTS_MAX_LIMIT = 50;
export const ARTIFACT_HIERARCHY_MAX_IDS = 100;
export const ARTIFACT_REMINDERS_MAX_LIMIT = 100;
export const ARTIFACT_QUERY_MAX_LIMIT = 500;
export type EmbedDataQuerySortField =
(typeof EMBED_DATA_QUERY_SORT_FIELDS)[number];
export interface EmbedDataQuery {
entity: "task";
location: { id: string; type: EmbedDataQueryLocationType };
filters?: EmbedDataQueryFilter[];
includeClosed?: boolean;
sort?: { field: EmbedDataQuerySortField; dir: "asc" | "desc" };
limit?: number;
cursor?: string;
}
export interface EmbedSearchRequest {
keywords?: string;
assetTypes?: string[];
excludeAssetTypes?: string[];
ancestors?: Array<{ externalId: string }>;
creatorIds?: string[];
orderBy?: EmbedSearchOrderBy;
orderDirection?: EmbedSearchOrderDirection;
cursor?: string;
limit?: number;
}
/** The `query()` sources the kit reads, with the protocol's input for each. */
export interface ArtifactQuerySourceInputs {
comments: ArtifactCommentsQuery;
customFields: ArtifactCustomFieldsQuery;
hierarchy: ArtifactHierarchyQuery;
inbox: ArtifactInboxQuery;
members: ArtifactMembersQuery;
queryWorkspace: ArtifactWorkspaceQuery;
quillHtml: { content: ArtifactQuillDelta };
reminders: ArtifactRemindersQuery;
statuses: ArtifactStatusesQuery;
tasks: ArtifactTasksQuery;
taskTypes: ArtifactTaskTypesQuery;
}
export type KitQuerySource = keyof ArtifactQuerySourceInputs;
/** The ref of a wire entity that names itself. */
export function refOf<K extends AssetKind>(
kind: K,
wire: { id: string; name: string; url: string },
): Ref<K> {
return { kind, id: wire.id, name: wire.name, url: wire.url };
}
/** Task priorities on the wire are ids: `1` urgent through `4` low. */
export const PRIORITY_IDS: Readonly<
Record<NonNullable<Task["priority"]>, string>
> = {
urgent: "1",
high: "2",
normal: "3",
low: "4",
};
/** ClickUp's object type name for each kind that has one. */
export const OBJECT_TYPES = {
space: "project",
folder: "category",
list: "subcategory",
task: "task",
comment: "comment",
reminder: "reminder",
} as const satisfies Partial<Record<AssetKind, string>>;
const KINDS_BY_OBJECT_TYPE = new Map(
Object.entries(OBJECT_TYPES).map(([kind, type]) => [type, kind]),
) as ReadonlyMap<string, keyof typeof OBJECT_TYPES>;
/** The kind behind one of ClickUp's object type names; undefined for types the model does not have. */
export function kindOfObjectType(
type: string,
): keyof typeof OBJECT_TYPES | undefined {
return KINDS_BY_OBJECT_TYPE.get(type);
}
+81
View File
@@ -0,0 +1,81 @@
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
import { listOrGet, useLiveQuery } from "../useQuery";
/** `primary` holds messages and mentions, `other` everything else, `reminder` reminders. */
export const INBOX_CATEGORIES = ["primary", "other", "reminder"] as const;
export const INBOX_STATES = ["active", "snoozed", "cleared"] as const;
export const NOTIFICATION_TYPES = [
"comment",
"mention",
"assignment",
"status",
"reminder",
"other",
] as const;
/**
* One inbox row: the notifications about one asset, whose name and url the
* bundle mirrors. For a reminder, `related` is the reminder itself.
*/
export interface InboxBundle extends Ref<"inboxBundle"> {
category: (typeof INBOX_CATEGORIES)[number];
state: (typeof INBOX_STATES)[number];
unreadCount: number;
mentionsMe: boolean;
assignedToMe: boolean;
/** When a snoozed bundle comes back; for a reminder, when it is due. */
snoozedUntil?: IsoDate;
clearedAt?: IsoDate;
/** The asset the bundle is about. */
related?: Ref;
/** Newest first. */
notifications: Array<{
type: (typeof NOTIFICATION_TYPES)[number];
at: IsoDate;
/** Who acted; absent for automations. */
actor?: Ref<"user">;
/** What the notification points at, e.g. the comment itself. */
related?: Ref;
}>;
}
/**
* `state: "cleared"` clears, `"snoozed"` snoozes until `snoozedUntil`,
* `"active"` brings a cleared or snoozed bundle back. `read` marks it read or
* unread; read the state back through `unreadCount`.
*/
export interface InboxBundlePatch {
state?: InboxBundle["state"];
snoozedUntil?: IsoDate;
read?: boolean;
}
export interface InboxSelector {
/** Defaults to `"active"`. */
state?: InboxBundle["state"];
category?: InboxBundle["category"];
/** Defaults to 100, at most 500. */
limit?: number;
}
export interface InboxApi {
list(selector?: InboxSelector): Promise<InboxBundle[]>;
/** Any state, cleared included. */
get(targets: Array<Ref<"inboxBundle"> | string>): Promise<InboxBundle[]>;
update(
bundle: Ref<"inboxBundle"> | string,
patch: InboxBundlePatch,
): Promise<void>;
}
export function useInbox(
selector: InboxSelector | Array<Ref<"inboxBundle"> | string> = {},
options: UseQueryOptions<InboxBundle[]> = {},
): UseQueryResult<InboxBundle[]> {
return useLiveQuery(
"inboxBundle",
selector,
(client) => listOrGet(client.inbox, selector),
options,
);
}
+178
View File
@@ -0,0 +1,178 @@
import * as z from "zod/v4-mini";
import type { Ref } from "../core";
import { toIso, toIsoOrEpoch } from "../host/dates";
import { pages } from "../host/paging";
import type {
ArtifactFeedResourceDto,
ArtifactInboxBundleDto,
ArtifactInboxNotificationDto,
} from "../host/wire";
import { kindOfObjectType } from "../host/wire";
import { byIds, clampLimit } from "../select";
import type { Session } from "../session";
import { fromHost } from "../validate";
import type { InboxBundle, InboxSelector } from "./inbox";
import { InboxBundleSchema } from "./schema";
type Notification = InboxBundle["notifications"][number];
export async function readInbox(
s: Session,
selector: InboxSelector,
): Promise<InboxBundle[]> {
const state = selector.state ?? "active";
const { category } = selector;
const bundles = await loadInbox(
s,
{ category, showCleared: state === "cleared" },
(bundle) =>
bundle.state === state && (!category || bundle.category === category),
clampLimit(selector.limit),
);
return fromHost(z.array(InboxBundleSchema), bundles, "inbox bundles");
}
/**
* The host serves the uncleared bundles and the cleared ones as two reads;
* the second is only made for ids the first did not have.
*/
export async function getInbox(
s: Session,
ids: string[],
): Promise<InboxBundle[]> {
const wanted = new Set(ids);
const keep = (bundle: InboxBundle) => wanted.has(bundle.id);
const found = await loadInbox(s, { showCleared: false }, keep, ids.length);
if (found.length < ids.length) {
found.push(
...(await loadInbox(
s,
{ showCleared: true },
keep,
ids.length - found.length,
)),
);
}
return fromHost(
z.array(InboxBundleSchema),
byIds(found, ids),
"inbox bundles",
);
}
/** The host's inbox pages, mapped, keeping what `keep` accepts until `limit` are kept. */
async function loadInbox(
s: Session,
query: { category?: InboxBundle["category"]; showCleared: boolean },
keep: (bundle: InboxBundle) => boolean,
limit: number,
): Promise<InboxBundle[]> {
const kept: InboxBundle[] = [];
for await (const page of pages((cursor) =>
s.host.query("inbox", {
filter: query.category === "reminder" ? "reminders" : "all",
showCleared: query.showCleared,
cursor,
}),
)) {
const mapped = await Promise.all(
page.bundles.map((dto) => mapInboxBundle(s, dto)),
);
kept.push(...mapped.filter(keep));
if (kept.length >= limit) {
break;
}
}
return kept.slice(0, limit);
}
async function mapInboxBundle(
s: Session,
wire: ArtifactInboxBundleDto,
): Promise<InboxBundle> {
const related = feedResourceRef(wire.root);
const notifications = await Promise.all(
[...wire.notifications]
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((notification) => mapNotification(s, notification)),
);
return {
kind: "inboxBundle",
id: wire.id,
name: wire.root.name,
url: related?.url ?? "",
category: toCategory(wire),
state: toState(wire.status),
unreadCount: wire.unreadCount,
mentionsMe: wire.hasMention,
assignedToMe: wire.hasAssignment,
snoozedUntil: toIso(wire.snoozedUntil),
clearedAt: toIso(wire.clearedAt),
related,
notifications,
};
}
async function mapNotification(
s: Session,
wire: ArtifactInboxNotificationDto,
): Promise<Notification> {
return {
type: toNotificationType(wire.type),
at: toIsoOrEpoch(wire.occurredAt),
actor: wire.actorId ? await s.member(wire.actorId) : undefined,
related: wire.object ? feedResourceRef(wire.object) : undefined,
};
}
function toCategory(wire: ArtifactInboxBundleDto): InboxBundle["category"] {
const isReminder =
wire.root.type === "reminder" ||
wire.notifications.some((notification) =>
notification.type.includes("reminder"),
);
if (isReminder) {
return "reminder";
}
return wire.bundleType === "messages" ? "primary" : "other";
}
function toState(status: string): InboxBundle["state"] {
switch (status) {
case "cleared":
return "cleared";
case "snoozed":
return "snoozed";
default:
return "active";
}
}
/** Folds ClickUp history item types onto the model's notification types. */
function toNotificationType(type: string): Notification["type"] {
if (type === "comment" || type === "attachment_comment") {
return "comment";
}
if (type === "tag" || type.endsWith("_tag") || type.includes("mention")) {
return "mention";
}
if (type.includes("assign")) {
return "assignment";
}
if (type === "status") {
return "status";
}
if (type.includes("reminder")) {
return "reminder";
}
return "other";
}
/** Only feed resources the model has a kind and a name for become refs. */
function feedResourceRef(resource: ArtifactFeedResourceDto): Ref | undefined {
const kind = kindOfObjectType(resource.type);
if (!kind || !resource.name) {
return undefined;
}
return { kind, id: resource.id, name: resource.name, url: resource.url };
}
+57
View File
@@ -0,0 +1,57 @@
import * as z from "zod/v4-mini";
import {
anyRef,
isoDate,
ref,
refFields,
schemaOf,
selection,
} from "../validate";
import type { InboxBundle, InboxBundlePatch, InboxSelector } from "./inbox";
import { INBOX_CATEGORIES, INBOX_STATES, NOTIFICATION_TYPES } from "./inbox";
export const InboxBundleSchema = schemaOf<InboxBundle>()(
z.object({
...refFields(["inboxBundle"]),
category: z.enum(INBOX_CATEGORIES),
state: z.enum(INBOX_STATES),
unreadCount: z.number(),
mentionsMe: z.boolean(),
assignedToMe: z.boolean(),
snoozedUntil: z.optional(isoDate),
clearedAt: z.optional(isoDate),
related: z.optional(anyRef),
notifications: z.array(
z.object({
type: z.enum(NOTIFICATION_TYPES),
at: isoDate,
actor: z.optional(ref(["user"])),
related: z.optional(anyRef),
}),
),
}),
);
export const InboxBundlePatchSchema = schemaOf<InboxBundlePatch>()(
z
.strictObject({
state: z.optional(z.enum(INBOX_STATES)),
snoozedUntil: z.optional(isoDate),
read: z.optional(z.boolean()),
})
.check(
z.refine(
(patch) =>
patch.state !== "snoozed" || patch.snoozedUntil !== undefined,
{ error: "Snoozing needs snoozedUntil" },
),
),
);
export const InboxSelectorSchema = schemaOf<InboxSelector>()(
z.strictObject({
state: z.optional(z.enum(INBOX_STATES)),
category: z.optional(z.enum(INBOX_CATEGORIES)),
...selection,
}),
);
+77
View File
@@ -0,0 +1,77 @@
import type { Ref } from "../core";
import { toEpochMinuteString } from "../host/dates";
import type { WireCall } from "../host/perform";
import { performAll } from "../host/perform";
import { ArtifactWriteActionType as Action } from "../host/wire";
import type { Session } from "../session";
import { resolve } from "../session";
import { applyPatch } from "../store";
import type { InboxBundle, InboxBundlePatch } from "./inbox";
import { getInbox } from "./read";
export async function updateInboxBundle(
s: Session,
target: Ref<"inboxBundle"> | string,
patch: InboxBundlePatch,
): Promise<void> {
const bundle = await resolve(s, "inboxBundle", target, getInbox);
await s.store.update(
bundle,
(current) => applyInboxPatch(current, patch),
() => performAll(s.host, inboxPatchCalls(bundle, patch)),
);
}
/** The ordered host calls that apply `patch` to `bundle`. */
export function inboxPatchCalls(
bundle: InboxBundle,
patch: InboxBundlePatch,
): WireCall[] {
const bundleId = bundle.id;
const calls: WireCall[] = [];
const snooze = (until: string): WireCall => [
Action.SnoozeInboxBundle,
{ bundleId, until: toEpochMinuteString(until) },
];
if (patch.state !== undefined && patch.state !== bundle.state) {
switch (patch.state) {
case "active":
calls.push([
bundle.state === "snoozed"
? Action.UnsnoozeInboxBundle
: Action.UnclearInboxBundle,
{ bundleId },
]);
break;
case "cleared":
calls.push([Action.ClearInboxBundle, { bundleId }]);
break;
case "snoozed":
calls.push(snooze(patch.snoozedUntil!));
break;
}
} else if (patch.snoozedUntil !== undefined) {
calls.push(snooze(patch.snoozedUntil));
}
if (patch.read === true) {
calls.push([Action.MarkInboxBundleRead, { bundleId }]);
} else if (patch.read === false) {
calls.push([Action.MarkInboxBundleUnread, { bundleId }]);
}
return calls;
}
/** The bundle as it will read back once the host has applied `patch`. */
export function applyInboxPatch(
bundle: InboxBundle,
patch: InboxBundlePatch,
): InboxBundle {
const { read, ...fields } = patch;
const next = applyPatch(bundle, fields);
if (read === true) {
next.unreadCount = 0;
} else if (read === false) {
next.unreadCount = Math.max(1, next.unreadCount);
}
return next;
}
+93
View File
@@ -0,0 +1,93 @@
export type {
AssetKind,
Delta,
IsoDate,
LiveClient,
LiveErrorCode,
MutationFunction,
MutationOptions,
MutationStatus,
QueryStatus,
Ref,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "./core";
export { clickup } from "./client";
export { ASSET_KINDS, keepPreviousData, LiveError, useMutation } from "./core";
export type { Folder, List, Space } from "./hierarchy/hierarchy";
export { useFolders, useLists, useSpaces } from "./hierarchy/hierarchy";
export type { CustomField } from "./customFields/customFields";
export {
CUSTOM_FIELD_TYPES,
useCustomFields,
} from "./customFields/customFields";
export type {
ChecklistItem,
CreateTaskInput,
DateFilter,
DateRange,
Status,
Task,
TaskPatch,
TaskSelector,
TaskType,
} from "./tasks/tasks";
export {
DATE_PRESETS,
PRIORITIES,
STATUS_TYPES,
TASK_ORDERS,
useStatuses,
useTaskQuery,
useTasks,
useTaskTypes,
} from "./tasks/tasks";
export type {
Comment,
CommentPatch,
CommentSelector,
CreateCommentInput,
} from "./comments/comments";
export {
COMMENT_ORDERS,
COMMENT_TYPES,
useComments,
} from "./comments/comments";
export type { Team, User } from "./people/people";
export { useTeams, useUsers } from "./people/people";
export type { InboxBundle, InboxBundlePatch } from "./inbox/inbox";
export {
INBOX_CATEGORIES,
INBOX_STATES,
NOTIFICATION_TYPES,
useInbox,
} from "./inbox/inbox";
export type {
CreateReminderInput,
Reminder,
ReminderPatch,
} from "./reminders/reminders";
export { useReminders } from "./reminders/reminders";
export type { SearchHit, SearchQuery } from "./search/search";
export { SEARCH_KINDS, SEARCH_ORDERS, useSearch } from "./search/search";
export type {
AvatarGroupProps,
AvatarProps,
CustomFieldValueProps,
PriorityFlagProps,
RichTextProps,
StatusBadgeProps,
TagProps,
} from "./components/components";
export {
Avatar,
AvatarGroup,
CustomFieldValue,
PriorityFlag,
RichText,
StatusBadge,
Tag,
} from "./components/components";
+45
View File
@@ -0,0 +1,45 @@
export interface Memo<T> {
(): Promise<T>;
/** Forgets the result, so the next call loads again. */
reset(): void;
}
/** Caches the first successful result; a rejection is forgotten so the next call retries. */
export function memo<T>(load: () => Promise<T>): Memo<T> {
let promise: Promise<T> | undefined;
const get = (): Promise<T> => {
promise ??= load().catch((error: unknown) => {
promise = undefined;
throw error;
});
return promise;
};
return Object.assign(get, {
reset: () => {
promise = undefined;
},
});
}
export interface MemoBy<A, T> {
(arg: A): Promise<T>;
reset(): void;
}
/** `memo` per argument; `keyOf` names the entry when the argument is not itself a usable key. */
export function memoBy<A, T>(
load: (arg: A) => Promise<T>,
keyOf: (arg: A) => unknown = (arg) => arg,
): MemoBy<A, T> {
const entries = new Map<unknown, Memo<T>>();
const get = (arg: A): Promise<T> => {
const key = keyOf(arg);
let entry = entries.get(key);
if (!entry) {
entry = memo(() => load(arg));
entries.set(key, entry);
}
return entry();
};
return Object.assign(get, { reset: () => entries.clear() });
}
+45
View File
@@ -0,0 +1,45 @@
import type { Ref, UseQueryOptions, UseQueryResult } from "../core";
import { listOrGet, useLiveQuery } from "../useQuery";
/** A workspace member; `image` is the avatar. */
export type User = Ref<"user">;
export interface Team extends Ref<"team"> {
members: Array<Ref<"user">>;
}
export interface UsersApi {
list(): Promise<User[]>;
get(targets: Array<Ref<"user"> | string>): Promise<User[]>;
}
export interface TeamsApi {
list(): Promise<Team[]>;
get(targets: Array<Ref<"team"> | string>): Promise<Team[]>;
}
/** Every member, or exactly the given ones. */
export function useUsers(
targets?: Array<Ref<"user"> | string>,
options: UseQueryOptions<User[]> = {},
): UseQueryResult<User[]> {
return useLiveQuery(
"user",
targets ?? {},
(client) => listOrGet(client.users, targets ?? {}),
options,
);
}
/** Every team, or exactly the given ones. */
export function useTeams(
targets?: Array<Ref<"team"> | string>,
options: UseQueryOptions<Team[]> = {},
): UseQueryResult<Team[]> {
return useLiveQuery(
"team",
targets ?? {},
(client) => listOrGet(client.teams, targets ?? {}),
options,
);
}
+131
View File
@@ -0,0 +1,131 @@
import * as z from "zod/v4-mini";
import type { Ref } from "../core";
import type { LiveBridge } from "../host/bridge";
import { pages } from "../host/paging";
import type {
ArtifactGroupDto,
ArtifactMemberDto,
ArtifactMembersResult,
ArtifactUserRef,
} from "../host/wire";
import { ARTIFACT_QUERY_MAX_LIMIT } from "../host/wire";
import { byIds } from "../select";
import type { Session } from "../session";
import { fromHost } from "../validate";
import type { Team, User } from "./people";
import { TeamSchema, UserSchema } from "./schema";
/** The `members` source, mapped: everyone in the workspace and every team. */
export interface Members {
users: User[];
teams: Team[];
}
export async function loadMembers(host: LiveBridge): Promise<Members> {
const members = [];
const groups = [];
for await (const page of pages((cursor) =>
host.query("members", {
includeGroups: true,
limit: ARTIFACT_QUERY_MAX_LIMIT,
cursor,
}),
)) {
members.push(...page.members);
groups.push(...(page.groups ?? []));
}
const peopleUrl = members[0]?.url ?? host.viewer().url;
return mapMembers({ members, groups }, peopleUrl);
}
export function mapMember(member: ArtifactMemberDto): User {
return {
kind: "user",
id: member.id,
name: member.displayName,
url: member.url,
image: member.avatarUrl,
};
}
/** Teams list only the members the workspace still knows. */
function mapMembers(wire: ArtifactMembersResult, peopleUrl: string): Members {
const users = wire.members.map(mapMember);
const usersById = new Map(users.map((user) => [user.id, user]));
const teams = (wire.groups ?? []).map(
(group): Team => ({
kind: "team",
id: group.id,
name: group.name,
url: peopleUrl,
members: group.memberIds.flatMap((id) => {
const user = usersById.get(id);
return user ? [user] : [];
}),
}),
);
return {
users: fromHost(z.array(UserSchema), users, "users"),
teams: fromHost(z.array(TeamSchema), teams, "teams"),
};
}
/** The user a task or comment names: the workspace member when known, else the user as the wire names them. */
export async function userOf(s: Session, wire: ArtifactUserRef): Promise<User> {
return (
(await s.member(wire.id)) ?? {
kind: "user",
id: wire.id,
name: wire.username,
url: wire.url,
image: wire.profilePicture,
}
);
}
export function usersOf(
s: Session,
wire: readonly ArtifactUserRef[],
): Promise<User[]> {
return Promise.all(wire.map((ref) => userOf(s, ref)));
}
/** The team a task names: the workspace team when known, else the team as the wire names it. */
async function teamOf(
s: Session,
wire: ArtifactGroupDto,
): Promise<Ref<"team">> {
return (
(await s.team(wire.id)) ?? {
kind: "team",
id: wire.id,
name: wire.name,
url: "",
}
);
}
export function teamsOf(
s: Session,
wire: readonly ArtifactGroupDto[],
): Promise<Ref<"team">[]> {
return Promise.all(wire.map((group) => teamOf(s, group)));
}
export async function readUsers(s: Session): Promise<User[]> {
return (await s.members()).users;
}
export async function getUsers(s: Session, ids: string[]): Promise<User[]> {
return byIds((await s.members()).users, ids);
}
export async function readTeams(s: Session): Promise<Team[]> {
return (await s.members()).teams;
}
export async function getTeams(s: Session, ids: string[]): Promise<Team[]> {
return byIds((await s.members()).teams, ids);
}
+9
View File
@@ -0,0 +1,9 @@
import * as z from "zod/v4-mini";
import { ref, refFields, schemaOf } from "../validate";
import type { Team, User } from "./people";
export const UserSchema = schemaOf<User>()(z.object(refFields(["user"])));
export const TeamSchema = schemaOf<Team>()(
z.object({ ...refFields(["team"]), members: z.array(ref(["user"])) }),
);
+102
View File
@@ -0,0 +1,102 @@
import * as z from "zod/v4-mini";
import type { Ref } from "../core";
import { asRef } from "../core";
import { toIso } from "../host/dates";
import { pages } from "../host/paging";
import type { ArtifactReminder } from "../host/wire";
import { ARTIFACT_REMINDERS_MAX_LIMIT } from "../host/wire";
import { byIds, clampLimit } from "../select";
import type { Session } from "../session";
import { getTasks } from "../tasks/read";
import { fromHost } from "../validate";
import type { Reminder, ReminderSelector } from "./reminders";
import { ReminderSchema } from "./schema";
export async function readReminders(
s: Session,
selector: ReminderSelector,
): Promise<Reminder[]> {
const wire = await loadReminders(
s,
selector.includeCompleted ?? false,
clampLimit(selector.limit),
);
return mapReminders(s, wire);
}
export async function getReminders(
s: Session,
ids: string[],
): Promise<Reminder[]> {
const wire = await loadReminders(s, true, Infinity);
return mapReminders(s, byIds(wire, ids));
}
/** The viewer's reminders, oldest page first, until `limit` are read or none are left. */
async function loadReminders(
s: Session,
includeCompleted: boolean,
limit: number,
): Promise<ArtifactReminder[]> {
const wire: ArtifactReminder[] = [];
for await (const page of pages((cursor) =>
s.host.query("reminders", {
includeCompleted,
limit: ARTIFACT_REMINDERS_MAX_LIMIT,
cursor,
}),
)) {
wire.push(...page.reminders);
if (wire.length >= limit) {
break;
}
}
return wire.slice(0, limit);
}
async function mapReminders(
s: Session,
wire: ArtifactReminder[],
): Promise<Reminder[]> {
const tasks = await relatedTasks(s, wire);
return fromHost(
z.array(ReminderSchema),
wire.map((reminder) => mapReminder(reminder, tasks)),
"reminders",
);
}
/** The wire names a reminder's task by id only; one tasks read names the ones the viewer can see. */
async function relatedTasks(
s: Session,
wire: readonly ArtifactReminder[],
): Promise<Map<string, Ref<"task">>> {
const ids = [...new Set(wire.flatMap((reminder) => reminder.taskId ?? []))];
if (ids.length === 0) {
return new Map();
}
const tasks = await getTasks(s, ids);
return new Map(tasks.map((task) => [task.id, asRef(task)]));
}
function mapReminder(
wire: ArtifactReminder,
tasks: ReadonlyMap<string, Ref<"task">>,
): Reminder {
return {
kind: "reminder",
id: wire.id,
name: wire.title,
url: "",
dueAt: toIso(wire.dueDate),
completedAt: toIso(wire.completedAt),
related: wire.taskId
? (tasks.get(wire.taskId) ?? {
kind: "task",
id: wire.taskId,
name: "",
url: "",
})
: undefined,
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
import { listOrGet, useLiveQuery } from "../useQuery";
export interface Reminder extends Ref<"reminder"> {
dueAt?: IsoDate;
/** Set once completed; there is no separate flag. */
completedAt?: IsoDate;
related?: Ref<"task">;
}
/** `completed: true` cannot be undone. */
export interface ReminderPatch {
dueAt?: IsoDate;
completed?: true;
}
export interface CreateReminderInput {
name: string;
dueAt?: IsoDate;
related?: Ref<"task"> | string;
}
export interface ReminderSelector {
includeCompleted?: boolean;
/** Defaults to 100, at most 500. */
limit?: number;
}
export interface RemindersApi {
list(selector?: ReminderSelector): Promise<Reminder[]>;
/** Completed ones included. */
get(targets: Array<Ref<"reminder"> | string>): Promise<Reminder[]>;
create(input: CreateReminderInput): Promise<Ref<"reminder">>;
update(
reminder: Ref<"reminder"> | string,
patch: ReminderPatch,
): Promise<void>;
}
export function useReminders(
selector: ReminderSelector | Array<Ref<"reminder"> | string> = {},
options: UseQueryOptions<Reminder[]> = {},
): UseQueryResult<Reminder[]> {
return useLiveQuery(
"reminder",
selector,
(client) => listOrGet(client.reminders, selector),
options,
);
}
+46
View File
@@ -0,0 +1,46 @@
import * as z from "zod/v4-mini";
import {
isoDate,
ref,
refFields,
refOrId,
schemaOf,
selection,
} from "../validate";
import type {
CreateReminderInput,
Reminder,
ReminderPatch,
ReminderSelector,
} from "./reminders";
export const ReminderSchema = schemaOf<Reminder>()(
z.object({
...refFields(["reminder"]),
dueAt: z.optional(isoDate),
completedAt: z.optional(isoDate),
related: z.optional(ref(["task"])),
}),
);
export const ReminderPatchSchema = schemaOf<ReminderPatch>()(
z.strictObject({
dueAt: z.optional(isoDate),
completed: z.optional(z.literal(true)),
}),
);
export const CreateReminderInputSchema = schemaOf<CreateReminderInput>()(
z.strictObject({
name: z.string().check(z.minLength(1, "A reminder needs a name")),
dueAt: z.optional(isoDate),
related: z.optional(refOrId("task")),
}),
);
export const ReminderSelectorSchema = schemaOf<ReminderSelector>()(
z.strictObject({
includeCompleted: z.optional(z.boolean()),
...selection,
}),
);
+72
View File
@@ -0,0 +1,72 @@
import type { Ref } from "../core";
import { idOf } from "../core";
import { toEpochMinuteString } from "../host/dates";
import type { WireCall } from "../host/perform";
import { createdRef, perform, performAll } from "../host/perform";
import { ArtifactWriteActionType as Action } from "../host/wire";
import type { Session } from "../session";
import { applyPatch } from "../store";
import type { CreateReminderInput, Reminder, ReminderPatch } from "./reminders";
export async function createReminder(
s: Session,
input: CreateReminderInput,
): Promise<Ref<"reminder">> {
const outcome = await perform(s.host, Action.CreateReminder, {
message: input.name,
...(input.dueAt ? { dueDate: toEpochMinuteString(input.dueAt) } : {}),
...(input.related ? { taskId: idOf(input.related) } : {}),
});
await Promise.all([
s.store.invalidate("reminder"),
s.store.invalidate("inboxBundle"),
]);
return createdRef("reminder", outcome, input.name);
}
export async function updateReminder(
s: Session,
target: Ref<"reminder"> | string,
patch: ReminderPatch,
): Promise<void> {
const reminder: Ref<"reminder"> =
typeof target === "string"
? { kind: "reminder", id: target, name: "", url: "" }
: target;
await s.store.update<Reminder>(
reminder as Reminder,
(current) => applyReminderPatch(current, patch),
() => performAll(s.host, reminderPatchCalls(reminder.id, patch)),
);
}
/** The ordered host calls that apply `patch` to the reminder. */
export function reminderPatchCalls(
reminderId: string,
patch: ReminderPatch,
): WireCall[] {
const calls: WireCall[] = [];
if (patch.dueAt !== undefined) {
calls.push([
Action.SnoozeReminder,
{ reminderId, until: toEpochMinuteString(patch.dueAt) },
]);
}
if (patch.completed) {
calls.push([Action.CompleteReminder, { reminderId }]);
}
return calls;
}
/** The reminder as it will read back once the host has applied `patch`. */
export function applyReminderPatch(
reminder: Reminder,
patch: ReminderPatch,
): Reminder {
const { completed, ...fields } = patch;
const next = applyPatch(reminder, fields);
if (completed) {
next.completedAt = new Date().toISOString();
}
return next;
}
+129
View File
@@ -0,0 +1,129 @@
import * as z from "zod/v4-mini";
import { preview } from "../comments/read";
import { LiveError } from "../core";
import type { LiveBridge } from "../host/bridge";
import { toIso, toIsoOrEpoch } from "../host/dates";
import { readError } from "../host/errors";
import type {
EmbedSearchOrderBy,
EmbedSearchRequest,
EmbedSearchResultItem,
} from "../host/wire";
import {
EMBED_SEARCH_DEFAULT_LIMIT,
EMBED_SEARCH_MAX_LIMIT,
kindOfObjectType,
OBJECT_TYPES,
} from "../host/wire";
import { clampLimit } from "../select";
import type { Session } from "../session";
import { fromHost } from "../validate";
import { SearchHitSchema } from "./schema";
import type { SearchHit, SearchQuery } from "./search";
import { SEARCH_KINDS } from "./search";
const SEARCH_TIMEOUT_MS = 30_000;
const SEARCH_ORDER_BY: Readonly<
Record<NonNullable<SearchQuery["orderBy"]>["field"], EmbedSearchOrderBy>
> = {
createdAt: "created_date",
updatedAt: "updated_date",
dueAt: "due_date",
};
export async function search(
s: Session,
query: SearchQuery,
): Promise<SearchHit[]> {
const limit = clampLimit(
query.limit,
EMBED_SEARCH_DEFAULT_LIMIT,
EMBED_SEARCH_MAX_LIMIT,
);
const { ancestor } = query;
const items = await collectSearch(s.host, {
...(query.keywords ? { keywords: query.keywords } : {}),
assetTypes: [...(query.kinds ?? SEARCH_KINDS)],
...(ancestor
? {
ancestors: [
{ externalId: `${OBJECT_TYPES[ancestor.kind]}:${ancestor.id}` },
],
}
: {}),
...(query.orderBy
? {
orderBy: SEARCH_ORDER_BY[query.orderBy.field],
orderDirection: query.orderBy.direction ?? "desc",
}
: {}),
limit,
});
return fromHost(
z.array(SearchHitSchema),
items
.flatMap((item) => {
const hit = mapSearchHit(item);
return hit ? [hit] : [];
})
.slice(0, limit),
"search hits",
);
}
/** Runs a streamed host search to completion. */
function collectSearch(
host: LiveBridge,
request: EmbedSearchRequest,
): Promise<EmbedSearchResultItem[]> {
return new Promise((resolve, reject) => {
let unsubscribe = (): void => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(
new LiveError("failed", "The search did not complete in time.", {
retryable: true,
}),
);
}, SEARCH_TIMEOUT_MS);
const settle = (fn: () => void): void => {
clearTimeout(timeout);
unsubscribe();
fn();
};
try {
unsubscribe = host.subscribeSearch(request, (update) => {
if (update.error) {
settle(() => reject(readError(update.error)));
} else if (update.done) {
settle(() => resolve(update.items));
}
});
} catch (error) {
settle(() => reject(error));
}
});
}
const SEARCHABLE: ReadonlySet<string> = new Set(SEARCH_KINDS);
/** Hits of kinds the kit does not search are dropped. */
function mapSearchHit(item: EmbedSearchResultItem): SearchHit | undefined {
const kind = kindOfObjectType(item.metadata.objectType);
if (!kind || !SEARCHABLE.has(kind)) {
return undefined;
}
const snippet = item.highlights?.snippets[0] ?? item.snippet;
return {
kind: kind as SearchHit["kind"],
id: item.metadata.objectId,
name: item.title || preview(snippet ?? ""),
url: item.url,
image: item.thumbnailUrl,
snippet,
createdAt: toIsoOrEpoch(item.createdAt),
updatedAt: toIsoOrEpoch(item.updatedAt),
viewedAt: toIso(item.viewedAt),
};
}
+42
View File
@@ -0,0 +1,42 @@
import * as z from "zod/v4-mini";
import {
EMBED_SEARCH_KEYWORDS_MAX_LENGTH,
EMBED_SEARCH_MAX_LIMIT,
} from "../host/wire";
import { isoDate, limit, orderBy, ref, refFields, schemaOf } from "../validate";
import type { SearchHit, SearchQuery } from "./search";
import { SEARCH_KINDS, SEARCH_ORDERS } from "./search";
export const SearchHitSchema = schemaOf<SearchHit>()(
z.object({
...refFields(SEARCH_KINDS),
snippet: z.optional(z.string()),
createdAt: isoDate,
updatedAt: isoDate,
viewedAt: z.optional(isoDate),
}),
);
export const SearchQuerySchema = schemaOf<SearchQuery>()(
z.strictObject({
keywords: z.optional(
z
.string()
.check(
z.minLength(1),
z.maxLength(
EMBED_SEARCH_KEYWORDS_MAX_LENGTH,
`At most ${EMBED_SEARCH_KEYWORDS_MAX_LENGTH} characters`,
),
),
),
kinds: z.optional(
z
.array(z.enum(SEARCH_KINDS))
.check(z.minLength(1, "Expected at least one kind")),
),
ancestor: z.optional(ref(["space", "folder", "list"])),
orderBy: orderBy(SEARCH_ORDERS),
limit: limit(EMBED_SEARCH_MAX_LIMIT),
}),
);
+47
View File
@@ -0,0 +1,47 @@
import type { IsoDate, Ref, UseQueryOptions, UseQueryResult } from "../core";
import { useLiveQuery } from "../useQuery";
export const SEARCH_KINDS = [
"task",
"list",
"folder",
"space",
"comment",
] as const;
export const SEARCH_ORDERS = ["createdAt", "updatedAt", "dueAt"] as const;
/** A ref plus why and when it matched. For the full asset, load it through its kind, e.g. `useTasks([hit.id])`. */
export interface SearchHit extends Ref<(typeof SEARCH_KINDS)[number]> {
/** A plain-text preview; the passage that matched when `keywords` were given. */
snippet?: string;
createdAt: IsoDate;
updatedAt: IsoDate;
/** When the viewer last opened it. */
viewedAt?: IsoDate;
}
export interface SearchQuery {
/** Matched against names, descriptions and comments. Omit for a plain listing, ordered by `orderBy`. */
keywords?: string;
/** Every searchable kind by default. */
kinds?: Array<SearchHit["kind"]>;
ancestor?: Ref<"space" | "folder" | "list">;
/** Relevance when omitted; `direction` defaults to `"desc"`. */
orderBy?: {
field: (typeof SEARCH_ORDERS)[number];
direction?: "asc" | "desc";
};
/** Defaults to 25, at most 50. */
limit?: number;
}
/** ClickUp's own search, run as the viewer; hits come in ClickUp's order. */
export function useSearch(
query: SearchQuery,
options: UseQueryOptions<SearchHit[]> = {},
): UseQueryResult<SearchHit[]> {
return useLiveQuery("search", query, (client) => client.search(query), {
...options,
cache: false,
});
}
+54
View File
@@ -0,0 +1,54 @@
import type { Ref } from "./core";
import { ARTIFACT_QUERY_MAX_LIMIT } from "./host/wire";
const DEFAULT_LIMIT = 100;
export function clampLimit(
limit: number | undefined,
fallback = DEFAULT_LIMIT,
max = ARTIFACT_QUERY_MAX_LIMIT,
): number {
return Math.min(limit ?? fallback, max);
}
type Identity = Pick<Ref, "kind" | "id">;
interface LoadedSelector {
/** Direct owner: the last ancestor. */
parent?: Identity;
/** Any ancestor. */
ancestor?: Identity;
limit?: number;
}
/** Applies a selector to an already-loaded collection; `limit` bounds the result only when given. */
export function selectLoaded<T extends Ref & { ancestors?: Ref[] }>(
assets: T[],
selector: LoadedSelector,
): T[] {
const { parent, ancestor, limit } = selector;
const selected = assets.filter((asset) => {
const ancestors = asset.ancestors ?? [];
return (
(!parent || same(ancestors[ancestors.length - 1], parent)) &&
(!ancestor || ancestors.some((ref) => same(ref, ancestor)))
);
});
return limit === undefined ? selected : selected.slice(0, limit);
}
function same(a: Identity | undefined, b: Identity): boolean {
return a !== undefined && a.kind === b.kind && a.id === b.id;
}
/** The items with these ids, in this order; ids the collection lacks are skipped. */
export function byIds<T extends { id: string }>(
items: readonly T[],
ids: string[],
): T[] {
const byId = new Map(items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}
+159
View File
@@ -0,0 +1,159 @@
import type { Ref } from "./core";
import { asRef, idOf, LiveError, pathOf } from "./core";
import type { Hierarchy, HierarchyReads } from "./hierarchy/read";
import { createHierarchyReads, HIERARCHY_KINDS } from "./hierarchy/read";
import type { LiveBridge } from "./host/bridge";
import { memo } from "./memo";
import type { Team, User } from "./people/people";
import type { Members } from "./people/read";
import { loadMembers } from "./people/read";
import type { Store } from "./store";
import { loadTaskTypes } from "./tasks/taskTypes";
import type { TaskType } from "./tasks/tasks";
/** The ids of the space, folder and list an asset sits in. */
export interface LocationIds {
spaceId?: string;
folderId?: string;
listId?: string;
}
type LocationRef = Ref<"space" | "folder" | "list">;
/**
* One connection to a host: its bridge, its query store, and the bundles
* read once and shared by every query, until a refetch or an invalidation
* of one of their kinds drops them.
*/
export interface Session {
host: LiveBridge;
store: Store;
hierarchy: HierarchyReads;
/** The members bundle; rejects when the grant is withheld. */
members(): Promise<Members>;
/** The workspace member with `id`, if any. */
member(id: string): Promise<User | undefined>;
/** The workspace team with `id`, if any. */
team(id: string): Promise<Team | undefined>;
/** The task type catalog; rejects when the grant is withheld. */
taskTypes(): Promise<TaskType[]>;
/**
* The refs of a location's ids, root first, as far as its space's tree
* lists them; a level the tree does not list (hidden, inaccessible,
* archived, or no hierarchy grant) is left out.
*/
path(ids: LocationIds): Promise<LocationRef[]>;
}
export function createSession(host: LiveBridge, store: Store): Session {
const hierarchy = createHierarchyReads(host);
const members = memo(() => loadMembers(host));
const users = memo(
async () => new Map((await members()).users.map((user) => [user.id, user])),
);
const teams = memo(
async () => new Map((await members()).teams.map((team) => [team.id, team])),
);
const taskTypes = memo(() => loadTaskTypes(host));
store.onRefresh(HIERARCHY_KINDS, hierarchy.reset);
store.onRefresh(["user", "team"], () => {
members.reset();
users.reset();
teams.reset();
});
store.onRefresh(["taskType"], taskTypes.reset);
return {
host,
store,
hierarchy,
members,
async member(id) {
const known = await users().catch(() => undefined);
return known?.get(id);
},
async team(id) {
const known = await teams().catch(() => undefined);
return known?.get(id);
},
taskTypes,
async path({ spaceId, folderId, listId }) {
if (!spaceId) {
return [];
}
const tree = await hierarchy.tree(spaceId).catch(() => undefined);
if (!tree) {
return [];
}
const deepest: LocationRef | undefined =
tree.lists.find((list) => list.id === listId) ??
tree.folders.find((folder) => folder.id === folderId) ??
tree.spaces.find((space) => space.id === spaceId);
return deepest
? [...(pathOf(deepest) as LocationRef[]), asRef(deepest)]
: [];
},
};
}
/** The full asset behind a ref or id: the copy on screen, else one read through the kind's `get`. */
export async function resolve<T extends Ref>(
s: Session,
kind: T["kind"],
target: Ref | string,
get: (s: Session, ids: string[]) => Promise<T[]>,
): Promise<T> {
const id = idOf(target);
const held = s.store.find<T>(kind, id);
if (held) {
return held;
}
const [found] = await get(s, [id]);
if (!found) {
throw new LiveError(
"invalid",
`No ${kind} with id ${id}, or it is not visible to you.`,
);
}
return found;
}
/** The `ancestor` or `parent` a selector names; its schema guarantees one. */
export function scopeOf<P, A>(selector: {
parent?: P;
ancestor?: A;
}): NonNullable<A | P> {
return (selector.ancestor ?? selector.parent)!;
}
/** The tree of the space a space or folder ref sits in. */
export async function treeOf(
s: Session,
ref: Ref<"space" | "folder">,
): Promise<Hierarchy> {
return s.hierarchy.tree(await spaceIdOf(s, ref));
}
/** The space a ref sits in: from its path when it is a full asset, else looked up by id. */
async function spaceIdOf(
s: Session,
ref: Ref<"space" | "folder">,
): Promise<string> {
if (ref.kind === "space") {
return ref.id;
}
const known = pathOf(ref).find((ancestor) => ancestor.kind === "space");
if (known) {
return known.id;
}
const { folders } = await s.hierarchy.lookup("folder", [ref.id]);
const folder = folders.find((candidate) => candidate.id === ref.id);
const space = folder?.ancestors.find((ancestor) => ancestor.kind === "space");
if (!space) {
throw new LiveError(
"invalid",
`No folder with id ${ref.id}, or it is not visible to you.`,
);
}
return space.id;
}
+394
View File
@@ -0,0 +1,394 @@
import type { AssetKind, Ref } from "./core";
import { ASSET_KINDS, LiveError } from "./core";
import type { QueryThing, Thing } from "./things";
import { isQuery, keyOf, Things } from "./things";
/** What a query lists: assets of one kind, search hits, SQL rows over tasks, or a body's HTML. */
export type QueryKind = AssetKind | "search" | "taskRow" | "html";
/** A stable cache key for one kind + selector; equal selectors share a key. */
export function queryKey(kind: QueryKind, selector: object): string {
return `${kind}:${stableStringify(selector)}`;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
if (typeof record.kind === "string" && typeof record.id === "string") {
return `${record.kind}#${record.id}`;
}
return `{${Object.keys(record)
.sort()
.filter((key) => record[key] !== undefined)
.map((key) => `${key}:${stableStringify(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
const ASSET_KIND_SET: ReadonlySet<string> = new Set(ASSET_KINDS);
/** What a hook reads for its query. */
export interface QueryState<T> {
data: T[] | undefined;
error: unknown;
/** When `data` landed: the host's answer this session, or the cached copy's stamp. */
at: number;
/** True while a request is in flight. */
fetching: boolean;
}
export interface SubscribeOptions {
/** False keeps this query off the cache: it neither paints from it nor lands in it. */
cache?: boolean;
}
/** The per-query state that lives only for this session. */
interface Runtime {
kind: QueryKind;
fetcher: (() => Promise<object[]>) | undefined;
promise: Promise<object[]> | undefined;
error: unknown;
cache: boolean;
listeners: Set<() => void>;
memo: { version: number; data: object[] | undefined } | undefined;
}
/**
* Every query on screen over one map of things: shared fetches, optimistic
* writes and invalidation. A hook paints what is held, from this session or
* the cache, and every mount asks the host again.
*/
export class Store {
readonly things = new Things();
private readonly runtime = new Map<string, Runtime>();
private readonly resets = new Map<QueryKind, Set<() => void>>();
private readonly changeListeners = new Set<() => void>();
/** Registers a bundle the queries of `kinds` read through; it is reset before any fresh read of those kinds. */
onRefresh(kinds: readonly QueryKind[], reset: () => void): void {
for (const kind of kinds) {
const resets = this.resets.get(kind) ?? new Set();
resets.add(reset);
this.resets.set(kind, resets);
}
}
private refresh(kind: QueryKind): void {
for (const reset of this.resets.get(kind) ?? []) {
reset();
}
}
/** Subscribes to a query, creating it on first use; identical keys share one entry and one in-flight request. */
subscribe<T extends object>(
key: string,
kind: QueryKind,
fetcher: () => Promise<T[]>,
listener: () => void,
options: SubscribeOptions = {},
): () => void {
let entry = this.runtime.get(key);
if (!entry) {
entry = {
kind,
fetcher: undefined,
promise: undefined,
error: undefined,
cache: true,
listeners: new Set(),
memo: undefined,
};
this.runtime.set(key, entry);
}
entry.fetcher = fetcher;
entry.cache = options.cache !== false;
entry.listeners.add(listener);
const query = this.things.query(key);
if (!entry.cache && query && this.things.hasPage(query)) {
this.things.delete(keyOf(query));
}
const { listeners } = entry;
return () => {
listeners.delete(listener);
};
}
state<T extends object>(key: string): QueryState<T> {
const entry = this.runtime.get(key);
const query = this.things.query(key);
const data = query ? (this.rowsOf(query, entry) as T[]) : undefined;
return {
data,
error: entry?.error,
at: data === undefined ? 0 : query!.at,
fetching: entry?.promise !== undefined,
};
}
/** The rows a query holds, defined only once every listed asset is present; the same array while nothing changed. */
private rowsOf(
query: QueryThing,
entry: Runtime | undefined,
): object[] | undefined {
const { version } = this.things;
const memo = entry?.memo;
if (memo?.version === version) {
return memo.data;
}
let data: object[] | undefined = query.rows ?? [];
for (const id of query.ids ?? []) {
const thing = this.things.get(id);
if (!thing) {
data = undefined;
break;
}
data.push(thing);
}
if (data && memo?.data && sameItems(data, memo.data)) {
data = memo.data;
}
if (entry) {
entry.memo = { version, data };
}
return data;
}
/** Asks the host for the query's data, sharing any request in flight; `fresh` also resets the bundles its kind reads through. */
fetch<T extends object>(key: string, fresh = false): Promise<T[]> {
const entry = this.runtime.get(key);
if (!entry) {
return Promise.reject(new Error(`Unknown query ${key}`));
}
if (fresh) {
this.refresh(entry.kind);
}
return this.load(key, entry) as Promise<T[]>;
}
private load(key: string, entry: Runtime): Promise<object[]> {
if (entry.promise) {
return entry.promise;
}
const { fetcher } = entry;
if (!fetcher) {
return Promise.reject(
new Error(`A ${entry.kind} query has no subscriber`),
);
}
const promise = withRetry(fetcher).then(
(rows) => {
if (entry.promise === promise) {
entry.promise = undefined;
this.ingest(key, entry, rows);
}
return rows;
},
(error: unknown) => {
if (entry.promise === promise) {
entry.promise = undefined;
entry.error = error;
if (isForbidden(error)) {
this.things.deleteQuery(key);
this.things.collect();
}
this.notify(entry.kind);
}
throw error;
},
);
entry.promise = promise;
entry.error = undefined;
this.notify(entry.kind);
return promise;
}
/** Stores a host answer: the rows become the current things and the query lists them. */
private ingest(key: string, entry: Runtime, rows: object[]): void {
entry.error = undefined;
const query: QueryThing = { kind: "query", id: key, at: Date.now() };
if (ASSET_KIND_SET.has(entry.kind)) {
for (const asset of rows as Ref[]) {
this.things.set(keyOf(asset), asset);
}
query.ids = (rows as Ref[]).map(keyOf);
} else {
query.rows = rows;
}
if (entry.cache) {
this.things.set(keyOf(query), query);
} else {
this.things.hold(keyOf(query), query);
}
this.things.collect();
this.notify(entry.kind);
this.changed();
}
/** Drops the least recently used off-screen queries until the things fit `budget`. */
evict(budget: number): void {
const inUse = (query: QueryThing) =>
(this.runtime.get(query.id)?.listeners.size ?? 0) > 0;
for (const query of this.things.evict(budget, inUse)) {
this.runtime.delete(query.id);
}
}
/** The copy of an asset any query holds. */
find<T extends Ref>(kind: AssetKind, id: string): T | undefined {
return this.things.get(`${kind}#${id}`) as T | undefined;
}
/**
* Runs a write optimistically: cached copies of `asset` show the patched
* result at once, the host executes after the user's approval, and every
* list of that kind on screen refetches. A denial or failure rolls back.
*/
async update<T extends Ref>(
asset: T,
apply: (current: T) => T,
perform: () => Promise<void>,
): Promise<void> {
const rollback = this.optimistic(asset, apply);
try {
await perform();
} catch (error) {
rollback();
throw error;
}
await this.invalidate(asset.kind);
}
private optimistic<T extends Ref>(
asset: Ref,
merge: (current: T) => T,
): () => void {
const key = keyOf(asset);
const current = this.things.get(key);
if (!current) {
return () => undefined;
}
const next = merge(current as T);
const swap = (from: object, to: Ref) => {
if (this.things.get(key) === from) {
this.things.set(key, to);
this.notify(asset.kind);
this.changed();
}
};
swap(current, next);
return () => swap(next, current as Ref);
}
/** Refetches every query of a kind on screen; the rest refetch when they come back. A task write also refreshes SQL rows. */
async invalidate(kind: QueryKind): Promise<void> {
this.refresh(kind);
const refetches: Array<Promise<unknown>> = [];
if (kind === "task") {
refetches.push(this.invalidate("taskRow"));
}
for (const [key, entry] of this.runtime) {
if (entry.kind === kind && entry.listeners.size > 0) {
refetches.push(this.load(key, entry).catch(() => undefined));
}
}
await Promise.all(refetches);
}
/** Drops every bundle and refetches every query on screen; the rest refetch when they come back. */
reload(): void {
for (const kind of this.resets.keys()) {
this.refresh(kind);
}
for (const [key, entry] of this.runtime) {
if (entry.listeners.size > 0) {
this.load(key, entry).catch(() => undefined);
}
}
}
/** Adopts a page from the cache, except queries the host already refused this session, and wakes every hook. */
seedPage(page: string, things: Thing[]): void {
const refused = (thing: Thing) =>
isQuery(thing) && this.runtime.get(thing.id)?.error !== undefined;
this.things.seedPage(
page,
things.filter((thing) => !refused(thing)),
);
this.notify();
}
/** Called after any host answer or optimistic change; seeding is not a change. */
onChange(listener: () => void): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
private changed(): void {
for (const listener of this.changeListeners) {
listener();
}
}
/** Wakes the hooks of `kind`, or all of them. */
private notify(kind?: QueryKind): void {
for (const entry of this.runtime.values()) {
if (kind === undefined || entry.kind === kind) {
for (const listener of entry.listeners) {
listener();
}
}
}
}
}
const RETRIES = 3;
const FIRST_RETRY_DELAY_MS = 1_000;
/** Runs `load`, retrying a `retryable` failure up to three times at 1 s, 2 s and 4 s; any other failure rejects at once. */
async function withRetry<T>(load: () => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt += 1) {
try {
return await load();
} catch (error) {
if (attempt >= RETRIES || !isTransient(error)) {
throw error;
}
await wait(FIRST_RETRY_DELAY_MS * 2 ** attempt);
}
}
}
function isTransient(error: unknown): boolean {
return error instanceof LiveError && error.retryable;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isForbidden(error: unknown): boolean {
return error instanceof LiveError && error.code === "forbidden";
}
function sameItems(a: readonly object[], b: readonly object[]): boolean {
return a.length === b.length && a.every((item, index) => item === b[index]);
}
/** The optimistic result of a patch: `undefined` leaves a field alone, `null` clears it, anything else replaces it. */
export function applyPatch<T extends object>(asset: T, patch: object): T {
const next = { ...asset } as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
} else if (value !== undefined) {
next[key] = value;
}
}
return next as T;
}
+148
View File
@@ -0,0 +1,148 @@
import type { LiveBridge } from "../host/bridge";
import { perform } from "../host/perform";
import { ArtifactWriteActionType as Action } from "../host/wire";
import type { ChecklistItem, Task } from "./tasks";
/** The `parent` value that puts a checklist item at the top level (`CHECKLIST_ITEM_NO_PARENT` in the protocol's schemas). */
const CHECKLIST_ITEM_NO_PARENT = "none";
/** Syncs the task's checklists to `next`, diffed by id. */
export async function syncChecklists(
host: LiveBridge,
task: Task,
next: ChecklistItem[],
): Promise<void> {
const taskId = task.id;
const current = new Map(task.checklists.map((list) => [list.id, list]));
for (const checklist of next) {
const existing = current.get(checklist.id);
if (!existing) {
const outcome = await perform(host, Action.CreateChecklist, {
taskId,
name: checklist.name,
});
const checklistId = outcome.result?.id;
if (checklistId) {
await syncChecklistItems(
host,
taskId,
checklistId,
[],
checklist.items,
);
}
continue;
}
current.delete(checklist.id);
if (existing.name !== checklist.name) {
await perform(host, Action.SetChecklistName, {
taskId,
checklistId: checklist.id,
name: checklist.name,
});
}
await syncChecklistItems(
host,
taskId,
checklist.id,
existing.items,
checklist.items,
);
}
for (const removed of current.values()) {
await perform(host, Action.DeleteChecklist, {
taskId,
checklistId: removed.id,
});
}
}
async function syncChecklistItems(
host: LiveBridge,
taskId: string,
checklistId: string,
current: ChecklistItem[],
next: ChecklistItem[],
): Promise<void> {
const target = { taskId, checklistId };
const existing = new Map(
flattenItems(current, CHECKLIST_ITEM_NO_PARENT).map((entry) => [
entry.item.id,
entry,
]),
);
const kept = new Set<string>();
const sync = async (
items: ChecklistItem[],
parent: string,
): Promise<void> => {
for (const item of items) {
const before = existing.get(item.id);
let itemId: string | undefined = item.id;
if (before) {
kept.add(item.id);
if (before.item.name !== item.name) {
await perform(host, Action.SetChecklistItemName, {
...target,
itemId: item.id,
name: item.name,
});
}
if (item.done !== undefined && before.item.done !== item.done) {
await perform(host, Action.SetChecklistItemResolved, {
...target,
itemId: item.id,
resolved: String(item.done),
});
}
if (before.parent !== parent) {
await perform(host, Action.SetChecklistItemParent, {
...target,
itemId: item.id,
parent,
});
}
} else {
const outcome = await perform(host, Action.CreateChecklistItem, {
...target,
name: item.name,
});
itemId = outcome.result?.id;
if (itemId && parent !== CHECKLIST_ITEM_NO_PARENT) {
await perform(host, Action.SetChecklistItemParent, {
...target,
itemId,
parent,
});
}
if (itemId && item.done) {
await perform(host, Action.SetChecklistItemResolved, {
...target,
itemId,
resolved: "true",
});
}
}
if (itemId) {
await sync(item.items, itemId);
}
}
};
await sync(next, CHECKLIST_ITEM_NO_PARENT);
for (const itemId of existing.keys()) {
if (!kept.has(itemId)) {
await perform(host, Action.DeleteChecklistItem, { ...target, itemId });
}
}
}
/** Every item at every depth with the id of the item above it. */
function flattenItems(
items: ChecklistItem[],
parent: string,
): Array<{ item: ChecklistItem; parent: string }> {
return items.flatMap((item) => [
{ item, parent },
...flattenItems(item.items, item.id),
]);
}
+157
View File
@@ -0,0 +1,157 @@
import type { Ref } from "../core";
import { toEpochMs } from "../host/dates";
import type {
EmbedDataQuery,
EmbedDataQueryFilter,
EmbedDataQueryFilterValue,
EmbedDataQuerySortField,
EmbedDataQueryStatusFilterValue,
} from "../host/wire";
import { PRIORITY_IDS } from "../host/wire";
import type { Status, TaskSelector } from "./tasks";
/** The wire status types behind each of the kit's; the inverse of `toStatusType`. */
const STATUS_BUCKETS: Readonly<
Record<Status["type"], Array<EmbedDataQueryStatusFilterValue["type"]>>
> = {
open: ["open", "unstarted", "placeholder"],
active: ["active", "custom"],
done: ["done"],
closed: ["closed"],
};
const DATE_PRESET_OPS = {
overdue: "overdue",
today: "today",
tomorrow: "tomorrow",
next7Days: "next7",
} as const;
type DateField = "startDate" | "dueDate" | "dateCreated" | "dateUpdated";
type AnyDateFilter = NonNullable<TaskSelector["dueAt"]>;
/** Open ends of a range; the host treats an epoch of 0 as unset. */
const EARLIEST = Date.UTC(1971, 0, 1);
const LATEST = Date.UTC(2200, 0, 1);
const WIRE_FIELDS: Readonly<
Record<NonNullable<TaskSelector["orderBy"]>["field"], EmbedDataQuerySortField>
> = {
name: "name",
priority: "priority",
status: "status",
startAt: "startDate",
dueAt: "dueDate",
createdAt: "dateCreated",
updatedAt: "dateUpdated",
};
/** The `queryData` filters behind a selector, in the host's `{ field, op, values }` form. */
export function taskFilters(selector: TaskSelector): EmbedDataQueryFilter[] {
const filters: EmbedDataQueryFilter[] = [];
if (selector.status) {
filters.push({
field: "status",
op: "EQ",
values: Array.isArray(selector.status)
? selector.status.map((ref) => ref.name)
: selector.status.type.flatMap((type) =>
STATUS_BUCKETS[type].map((wireType) => ({ type: wireType })),
),
});
}
if (selector.assignees) {
filters.push(assigneesFilter(selector.assignees));
}
if (selector.priority) {
filters.push({
field: "priority",
op: "EQ",
values: selector.priority.map((level) => PRIORITY_IDS[level]),
});
}
if (selector.tags) {
filters.push({ field: "tag", op: "ANY", values: selector.tags });
}
if (selector.name) {
filters.push({
field: "name",
op: "EQ",
values: [{ method: "contains", value: selector.name.contains }],
});
}
const dates: Array<[DateField, AnyDateFilter | undefined]> = [
["startDate", selector.startAt],
["dueDate", selector.dueAt],
["dateCreated", selector.createdAt],
["dateUpdated", selector.updatedAt],
];
for (const [field, filter] of dates) {
if (filter !== undefined) {
filters.push(dateFilter(field, filter));
}
}
return filters;
}
/** The `queryData` sort behind a selector; the host's own order without one. */
export function taskSort(selector: TaskSelector): EmbedDataQuery["sort"] {
return selector.orderBy
? {
field: WIRE_FIELDS[selector.orderBy.field],
dir: selector.orderBy.direction ?? "asc",
}
: undefined;
}
function assigneesFilter(
assignees: NonNullable<TaskSelector["assignees"]>,
): EmbedDataQueryFilter {
if (assignees === "none") {
return { field: "assignee", op: "NOT SET", values: [] };
}
if (assignees === "any") {
return { field: "assignee", op: "IS SET", values: [] };
}
return { field: "assignee", op: "ANY", values: assignees.map(assigneeValue) };
}
/** Ids are numeric on the wire where they can be; a team is the app's `group_id` token. */
function assigneeValue(
assignee: Ref<"user" | "team"> | "me",
): EmbedDataQueryFilterValue {
if (assignee === "me") {
return { me: true };
}
const numeric = Number(assignee.id);
const id = Number.isFinite(numeric) ? numeric : assignee.id;
return assignee.kind === "team" ? { group_id: id } : id;
}
function dateFilter(
field: DateField,
filter: AnyDateFilter,
): EmbedDataQueryFilter {
if (filter === "none") {
return { field, op: "NOT SET", values: [] };
}
if (filter === "any") {
return { field, op: "IS SET", values: [] };
}
if (typeof filter === "string") {
return { field, op: "EQ", values: [{ op: DATE_PRESET_OPS[filter] }] };
}
return {
field,
op: "EQ",
values: [
{
op: "ra",
startDate:
filter.from === undefined ? EARLIEST : toEpochMs(filter.from),
dueDate: filter.to === undefined ? LATEST : toEpochMs(filter.to),
},
],
};
}
+167
View File
@@ -0,0 +1,167 @@
import * as z from "zod/v4-mini";
import { DEFAULT_COLOR } from "../components/palette";
import type { Ref } from "../core";
import { mapCustomFieldValues } from "../customFields/read";
import type { LiveBridge } from "../host/bridge";
import { toIso, toIsoOrEpoch } from "../host/dates";
import { chunk, pages } from "../host/paging";
import { paragraph } from "../host/quill";
import type {
ArtifactChecklistDto,
ArtifactChecklistItemDto,
ArtifactTaskDto,
} from "../host/wire";
import {
ARTIFACT_QUERY_MAX_TASK_IDS,
EMBED_DATA_QUERY_MAX_LIMIT,
OBJECT_TYPES,
refOf,
} from "../host/wire";
import { teamsOf, userOf, usersOf } from "../people/read";
import { clampLimit } from "../select";
import type { Session } from "../session";
import { scopeOf } from "../session";
import { fromHost } from "../validate";
import { taskFilters, taskSort } from "./filters";
import { TaskRowsSchema, TaskSchema } from "./schema";
import { mapStatus } from "./statuses";
import { taskTypeOf } from "./taskTypes";
import type { ChecklistItem, Task, TaskSelector } from "./tasks";
import { PRIORITIES } from "./tasks";
export async function readTasks(
s: Session,
selector: TaskSelector,
): Promise<Task[]> {
const limit = clampLimit(selector.limit);
const scope = scopeOf(selector);
const ids = await listTaskIds(
s.host,
typeof scope === "string" ? { kind: "list", id: scope } : scope,
selector,
limit,
);
return getTasks(s, ids.slice(0, limit));
}
/** Full tasks in the order of `ids`. */
export async function getTasks(s: Session, ids: string[]): Promise<Task[]> {
const batches = await Promise.all(
chunk(ids, ARTIFACT_QUERY_MAX_TASK_IDS).map(async (batch) => {
const dtos = await s.host.query("tasks", { ids: batch });
const byId = new Map(dtos.map((dto) => [dto.id, dto]));
return Promise.all(
batch.flatMap((id) => {
const dto = byId.get(id);
return dto ? [mapTask(s, dto)] : [];
}),
);
}),
);
return fromHost(z.array(TaskSchema), batches.flat(), "tasks");
}
/** The host relays the workspace query's own `{ rows }` under `result`. */
export async function queryTasks(
s: Session,
sql: string,
): Promise<Array<Record<string, unknown>>> {
const { result } = await s.host.query("queryWorkspace", { sql });
return fromHost(TaskRowsSchema, result, "rows").rows;
}
/** The ids the host's list view returns for the scope, filtered and sorted server-side. */
async function listTaskIds(
host: LiveBridge,
scope: Pick<Ref<"space" | "folder" | "list">, "kind" | "id">,
selector: TaskSelector,
limit: number,
): Promise<string[]> {
const ids: string[] = [];
for await (const page of pages((cursor) =>
host.queryData({
entity: "task",
location: { id: scope.id, type: OBJECT_TYPES[scope.kind] },
filters: taskFilters(selector),
includeClosed: selector.includeClosed ?? false,
sort: taskSort(selector),
limit: EMBED_DATA_QUERY_MAX_LIMIT,
cursor,
}),
)) {
ids.push(...page.rows.map((row) => row.id));
if (ids.length >= limit) {
break;
}
}
return ids;
}
async function mapTask(s: Session, wire: ArtifactTaskDto): Promise<Task> {
const [location, type, creator, users, teams, watchers, customFields] =
await Promise.all([
s.path(wire.location),
taskTypeOf(s, wire.typeId),
userOf(s, wire.creator),
usersOf(s, wire.assignees),
teamsOf(s, wire.groupAssignees),
usersOf(s, wire.followers),
mapCustomFieldValues(s, wire.customFields ?? []),
]);
return {
kind: "task",
id: wire.id,
name: wire.name,
url: wire.url,
ancestors: wire.parent
? [...location, refOf("task", wire.parent)]
: location,
otherLists: wire.locations.map((list) => refOf("list", list)),
private: wire.private,
createdAt: toIsoOrEpoch(wire.dateCreated),
updatedAt: toIsoOrEpoch(wire.dateUpdated),
startAt: toIso(wire.startDate),
dueAt: toIso(wire.dueDate),
status: mapStatus(wire.status),
type,
priority: toPriority(wire.priority),
points: wire.points,
tags: wire.tags
.map((tag) => ({ name: tag.name, color: tag.tagBg || DEFAULT_COLOR }))
.sort((a, b) => a.name.localeCompare(b.name)),
customFields,
body: wire.descriptionQuill ?? paragraph(wire.markdownDescription),
creator,
assignees: [...users, ...teams],
watchers,
checklists: wire.checklists.map(mapChecklist),
};
}
function toPriority(value: string | undefined): Task["priority"] {
return (PRIORITIES as readonly string[]).includes(value ?? "")
? (value as Task["priority"])
: undefined;
}
/** A checklist is a heading with no `done`; its items, at any depth, are checkable. */
function mapChecklist(wire: ArtifactChecklistDto): ChecklistItem {
return {
id: wire.id,
name: wire.name,
items: sortByOrder(wire.items).map(mapChecklistItem),
};
}
function mapChecklistItem(wire: ArtifactChecklistItemDto): ChecklistItem {
return {
id: wire.id,
name: wire.name,
done: wire.resolved,
items: sortByOrder(wire.children).map(mapChecklistItem),
};
}
function sortByOrder<T extends { orderindex: number }>(items: T[]): T[] {
return [...items].sort((a, b) => a.orderindex - b.orderindex);
}
+212
View File
@@ -0,0 +1,212 @@
import * as z from "zod/v4-mini";
import { CUSTOM_FIELD_TYPES } from "../customFields/customFields";
import {
EMBED_DATA_QUERY_MAX_FILTER_VALUES,
EMBED_DATA_QUERY_VALUE_MAX_LENGTH,
} from "../host/wire";
import type { Schema } from "../validate";
import {
contains,
delta,
id,
isoDate,
orderBy,
ref,
refFields,
refOrId,
schemaOf,
scoped,
selection,
} from "../validate";
import type {
ChecklistItem,
CreateTaskInput,
DateFilter,
DateRange,
Status,
StatusSelector,
Task,
TaskPatch,
TaskSelector,
TaskType,
} from "./tasks";
import { DATE_PRESETS, PRIORITIES, STATUS_TYPES, TASK_ORDERS } from "./tasks";
export const StatusSchema = schemaOf<Status>()(
z.object({
...refFields(["status"]),
type: z.enum(STATUS_TYPES),
color: z.string(),
}),
);
export const StatusSelectorSchema = schemaOf<StatusSelector>()(
z.strictObject({
parent: z.union([id, ref(["space", "folder", "list"])], {
error:
"Expected a space, folder or list ref, from useSpaces, useFolders or useLists, or a list id",
}),
}),
);
const priority = z.enum(PRIORITIES);
const assignee = ref(["user", "team"]);
const tag = z.object({ name: z.string(), color: z.string() });
const customFieldValue = z.object({
id,
name: z.string(),
type: z.enum(CUSTOM_FIELD_TYPES),
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.array(ref(["user", "team", "task"])),
]),
});
export const TaskTypeSchema = schemaOf<TaskType>()(
z.object({
...refFields(["taskType"]),
description: z.optional(z.string()),
}),
);
export const ChecklistItemSchema: Schema<ChecklistItem> = z.object({
id: z.string(),
name: z.string(),
done: z.optional(z.boolean()),
get items() {
return z.array(ChecklistItemSchema);
},
});
export const TaskSchema = schemaOf<Task>()(
z.object({
...refFields(["task"]),
ancestors: z.array(ref(["space", "folder", "list", "task"])),
otherLists: z.array(ref(["list"])),
private: z.boolean(),
createdAt: isoDate,
updatedAt: isoDate,
startAt: z.optional(isoDate),
dueAt: z.optional(isoDate),
status: StatusSchema,
type: TaskTypeSchema,
priority: z.optional(priority),
points: z.optional(z.number()),
tags: z.array(tag),
customFields: z.array(customFieldValue),
body: delta,
creator: ref(["user"]),
assignees: z.array(assignee),
watchers: z.array(ref(["user"])),
checklists: z.array(ChecklistItemSchema),
}),
);
export const TaskPatchSchema = schemaOf<TaskPatch>()(
z.strictObject({
name: z.optional(z.string()),
bodyMarkdown: z.optional(z.string()),
status: z.optional(ref(["status"])),
type: z.optional(refOrId("taskType")),
priority: z.optional(z.nullable(priority)),
points: z.optional(z.nullable(z.number())),
startAt: z.optional(z.nullable(isoDate)),
dueAt: z.optional(z.nullable(isoDate)),
parent: z.optional(refOrId("task")),
assignees: z.optional(z.array(assignee)),
watchers: z.optional(z.array(ref(["user"]))),
tags: z.optional(z.array(tag)),
customFields: z.optional(z.array(customFieldValue)),
checklists: z.optional(z.array(ChecklistItemSchema)),
}),
);
export const CreateTaskInputSchema = schemaOf<CreateTaskInput>()(
z.strictObject({
list: refOrId("list"),
parent: z.optional(refOrId("task")),
name: z.string().check(z.minLength(1, "A task needs a name")),
bodyMarkdown: z.optional(z.string()),
status: z.optional(ref(["status"])),
type: z.optional(refOrId("taskType")),
priority: z.optional(priority),
dueAt: z.optional(isoDate),
startAt: z.optional(isoDate),
assignees: z.optional(z.array(assignee)),
}),
);
const filterValues = [
z.minLength(1, "Expected at least one value"),
z.maxLength(
EMBED_DATA_QUERY_MAX_FILTER_VALUES,
`At most ${EMBED_DATA_QUERY_MAX_FILTER_VALUES} values per filter`,
),
];
const dateRange: Schema<DateRange> = z
.strictObject({ from: z.optional(isoDate), to: z.optional(isoDate) })
.check(
z.refine((range) => range.from !== undefined || range.to !== undefined, {
error: "A date range needs from or to",
}),
);
function dateFilter<E extends string = never>(
...extra: E[]
): Schema<DateFilter | E> {
const presets = [...DATE_PRESETS, ...extra] as [string, ...string[]];
return z.union([z.enum(presets), dateRange], {
error: `Expected ${presets.map((preset) => `"${preset}"`).join(", ")}, or { from?, to? } as ISO 8601 dates`,
}) as Schema<DateFilter | E>;
}
export const TaskSelectorSchema = schemaOf<TaskSelector>()(
z
.strictObject({
parent: z.optional(refOrId("list")),
ancestor: z.optional(ref(["space", "folder", "list"])),
...selection,
includeClosed: z.optional(z.boolean()),
status: z.optional(
z.union(
[
z.array(ref(["status"])).check(...filterValues),
z.strictObject({ type: z.array(z.enum(STATUS_TYPES)) }),
],
{
error: `Expected statuses from useStatuses, or { type: [${STATUS_TYPES.map((type) => `"${type}"`).join(" | ")}] }`,
},
),
),
assignees: z.optional(
z.union(
[
z
.array(z.union([ref(["user", "team"]), z.literal("me")]))
.check(...filterValues),
z.literal("none"),
z.literal("any"),
],
{
error:
'Expected user or team refs, as useUsers and useTeams return, or "me"; or "none" or "any"',
},
),
),
priority: z.optional(z.array(priority).check(...filterValues)),
tags: z.optional(z.array(z.string()).check(...filterValues)),
name: contains(EMBED_DATA_QUERY_VALUE_MAX_LENGTH),
startAt: z.optional(dateFilter("none", "any")),
dueAt: z.optional(dateFilter("none", "any", "overdue")),
createdAt: z.optional(dateFilter()),
updatedAt: z.optional(dateFilter()),
orderBy: orderBy(TASK_ORDERS),
})
.check(scoped),
);
export const TaskRowsSchema = z.object({
rows: z.array(z.record(z.string(), z.unknown())),
});
+51
View File
@@ -0,0 +1,51 @@
import * as z from "zod/v4-mini";
import { STATUS_TYPE_COLORS } from "../components/palette";
import type { ArtifactStatusDto, ArtifactStatusType } from "../host/wire";
import type { Session } from "../session";
import { fromHost } from "../validate";
import { StatusSchema } from "./schema";
import type { Status, StatusSelector } from "./tasks";
export async function readStatuses(
s: Session,
{ parent }: StatusSelector,
): Promise<Status[]> {
const location =
typeof parent === "string"
? { kind: "list" as const, id: parent }
: { kind: parent.kind, id: parent.id };
const { statuses } = await s.host.query("statuses", { location });
return fromHost(z.array(StatusSchema), mapStatuses(statuses), "statuses");
}
export function mapStatus(wire: ArtifactStatusDto): Status {
const type = toStatusType(wire.type);
return {
kind: "status",
id: wire.id,
name: wire.name,
url: "",
type,
color: wire.color || STATUS_TYPE_COLORS[type],
};
}
/** A location's statuses in board order. */
function mapStatuses(wire: ArtifactStatusDto[]): Status[] {
return [...wire].sort((a, b) => a.orderindex - b.orderindex).map(mapStatus);
}
/** Collapses the wire's seven status categories onto the model's four. */
function toStatusType(type: ArtifactStatusType | string): Status["type"] {
switch (type) {
case "done":
return "done";
case "closed":
return "closed";
case "active":
case "custom":
return "active";
default:
return "open";
}
}
+43
View File
@@ -0,0 +1,43 @@
import * as z from "zod/v4-mini";
import type { LiveBridge } from "../host/bridge";
import type { Session } from "../session";
import { fromHost } from "../validate";
import { TaskTypeSchema } from "./schema";
import type { TaskType } from "./tasks";
/** The built-in Task type, which a task without a `typeId` has. */
const DEFAULT_TASK_TYPE: TaskType = {
kind: "taskType",
id: "0",
name: "Task",
url: "",
};
/** The `taskTypes` source, mapped: the workspace's whole catalog, unpaged. */
export async function loadTaskTypes(host: LiveBridge): Promise<TaskType[]> {
const { taskTypes } = await host.query("taskTypes", {});
return taskTypes.map((wire) => ({
kind: "taskType",
id: String(wire.id),
name: wire.name,
url: "",
description: wire.description,
}));
}
export async function readTaskTypes(s: Session): Promise<TaskType[]> {
return fromHost(z.array(TaskTypeSchema), await s.taskTypes(), "task types");
}
/** The task's type; the built-in Task type when the catalog does not list it. */
export async function taskTypeOf(
s: Session,
typeId: number | undefined,
): Promise<TaskType> {
if (typeId === undefined) {
return DEFAULT_TASK_TYPE;
}
const id = String(typeId);
const entries = await s.taskTypes();
return entries.find((candidate) => candidate.id === id) ?? DEFAULT_TASK_TYPE;
}
+236
View File
@@ -0,0 +1,236 @@
import type {
Delta,
IsoDate,
Ref,
UseQueryOptions,
UseQueryResult,
} from "../core";
import type { CustomField } from "../customFields/customFields";
import { listOrGet, useLiveQuery } from "../useQuery";
export const PRIORITIES = ["urgent", "high", "normal", "low"] as const;
/** `open` is not started, `active` in progress, `done` finished, and `closed` the list's final status. */
export const STATUS_TYPES = ["open", "active", "done", "closed"] as const;
export interface Status extends Ref<"status"> {
type: (typeof STATUS_TYPES)[number];
color: string;
}
/** The effective statuses of a list, folder or space: what a task created there uses, in board order. */
export interface StatusSelector {
/** A bare string is a list id. */
parent: Ref<"space" | "folder" | "list"> | string;
}
export interface Task extends Ref<"task"> {
/** The space, folder and home list, then the parent task if this is a subtask. */
ancestors: Array<Ref<"space" | "folder" | "list" | "task">>;
/** Other lists the task appears in, besides its home list. */
otherLists: Array<Ref<"list">>;
private: boolean;
createdAt: IsoDate;
updatedAt: IsoDate;
startAt?: IsoDate;
dueAt?: IsoDate;
status: Status;
type: TaskType;
priority?: (typeof PRIORITIES)[number];
/** Sprint points. */
points?: number;
tags: Array<{ name: string; color: string }>;
/** Set fields only; definitions are in `useCustomFields`, matched by `id`. `value` follows `type`: dates are `IsoDate`, drop-downs and labels option names, attachments urls, users and tasks refs. */
customFields: Array<{
id: string;
name: string;
type: CustomField["type"];
value:
| string
| number
| boolean
| string[]
| Ref<"user" | "team" | "task">[];
}>;
/** The description; render it with `RichText`. */
body: Delta;
creator: Ref<"user">;
assignees: Array<Ref<"user" | "team">>;
watchers: Array<Ref<"user">>;
checklists: ChecklistItem[];
}
/** A task's checklists are headings; the items nested below them are checkable, at any depth. */
export interface ChecklistItem {
/** Empty for an entry that does not exist yet. */
id: string;
name: string;
/** Absent on headings. */
done?: boolean;
items: ChecklistItem[];
}
/**
* The fields to change: `undefined` leaves one alone, `null` clears it.
* Collections replace the whole list, so read-modify-write them; checklists
* are diffed by `id`, so keep existing ids and give new entries an empty one.
* @example
* await clickup.tasks.update(task, { dueAt: null, assignees: [...task.assignees, user] });
*/
export interface TaskPatch {
name?: string;
/** Replaces the description; read it back as `body`. */
bodyMarkdown?: string;
/** One of the home list's statuses, from `useStatuses`. */
status?: Ref<"status">;
type?: Ref<"taskType"> | string;
priority?: Task["priority"] | null;
points?: number | null;
startAt?: IsoDate | null;
dueAt?: IsoDate | null;
/** Makes the task a subtask of `parent`. */
parent?: Ref<"task"> | string;
assignees?: Task["assignees"];
/** Only the viewer can be added or removed. */
watchers?: Task["watchers"];
tags?: Task["tags"];
/** Users, tasks and attachments fields cannot be written yet and reject with `forbidden`. */
customFields?: Task["customFields"];
checklists?: Task["checklists"];
}
/** `list` is where the task goes; `parent` makes it a subtask of that task. */
export interface CreateTaskInput {
list: Ref<"list"> | string;
parent?: Ref<"task"> | string;
name: string;
/** The description, as markdown. */
bodyMarkdown?: string;
status?: Ref<"status">;
type?: Ref<"taskType"> | string;
priority?: Task["priority"];
dueAt?: IsoDate;
startAt?: IsoDate;
assignees?: Task["assignees"];
}
/** Relative days, in the viewer's timezone. */
export const DATE_PRESETS = ["today", "tomorrow", "next7Days"] as const;
/** Inclusive day bounds; give one or both. */
export interface DateRange {
from?: IsoDate;
to?: IsoDate;
}
export type DateFilter = (typeof DATE_PRESETS)[number] | DateRange;
export const TASK_ORDERS = [
"name",
"priority",
"status",
"startAt",
"dueAt",
"createdAt",
"updatedAt",
] as const;
/**
* Needs `parent` or `ancestor`. The other fields filter and sort on the
* server before `limit` applies.
* @example
* useTasks({ ancestor: space, assignees: ["me"], dueAt: "overdue" });
* useTasks({ parent: list, status: { type: ["active"] }, orderBy: { field: "priority" } });
*/
export interface TaskSelector {
parent?: Ref<"list"> | string;
ancestor?: Ref<"space" | "folder" | "list">;
/** Defaults to 100, at most 500. */
limit?: number;
/** Defaults to false. Hides tasks in the list's final status only; `done` tasks still come. */
includeClosed?: boolean;
/** Statuses from `useStatuses`, or every status of these types. */
status?: Ref<"status">[] | { type: Status["type"][] };
/** Users and teams, `"me"` for the viewer; or `"none"` for unassigned tasks, `"any"` for assigned ones. */
assignees?: Array<Ref<"user" | "team"> | "me"> | "none" | "any";
priority?: Array<NonNullable<Task["priority"]>>;
tags?: string[];
/** Tasks whose name contains this text. Descriptions and comments are `useSearch`'s job. */
name?: { contains: string };
startAt?: DateFilter | "none" | "any";
dueAt?: DateFilter | "none" | "any" | "overdue";
createdAt?: DateFilter;
updatedAt?: DateFilter;
/** The host's own order when omitted; `direction` defaults to `"asc"`. */
orderBy?: { field: (typeof TASK_ORDERS)[number]; direction?: "asc" | "desc" };
}
/** One of the workspace's task types, e.g. Task, Milestone, Bug. */
export interface TaskType extends Ref<"taskType"> {
description?: string;
}
export interface TasksApi {
list(selector: TaskSelector): Promise<Task[]>;
get(targets: Array<Ref<"task"> | string>): Promise<Task[]>;
create(input: CreateTaskInput): Promise<Ref<"task">>;
update(task: Ref<"task"> | string, patch: TaskPatch): Promise<void>;
/**
* One read-only `SELECT … FROM tasks`, as the viewer; the rows hold the selected columns by name.
* @example
* clickup.tasks.query(`SELECT status, COUNT(*) AS n FROM tasks WHERE list = '${LIST_ID}' GROUP BY status`)
*/
query(sql: string): Promise<Array<Record<string, unknown>>>;
/** The workspace's task types. */
types(): Promise<TaskType[]>;
/** The effective statuses of a list, folder or space. */
statuses(selector: StatusSelector): Promise<Status[]>;
}
export function useTasks(
selector: TaskSelector | Array<Ref<"task"> | string>,
options: UseQueryOptions<Task[]> = {},
): UseQueryResult<Task[]> {
return useLiveQuery(
"task",
selector,
(client) => listOrGet(client.tasks, selector),
options,
);
}
/** The rows of `clickup.tasks.query(sql)`, refetched after every task write. */
export function useTaskQuery(
sql: string,
options: UseQueryOptions<Array<Record<string, unknown>>> = {},
): UseQueryResult<Array<Record<string, unknown>>> {
return useLiveQuery(
"taskRow",
{ sql },
(client) => client.tasks.query(sql),
options,
);
}
export function useTaskTypes(
options: UseQueryOptions<TaskType[]> = {},
): UseQueryResult<TaskType[]> {
return useLiveQuery(
"taskType",
{},
(client) => client.tasks.types(),
options,
);
}
export function useStatuses(
selector: StatusSelector,
options: UseQueryOptions<Status[]> = {},
): UseQueryResult<Status[]> {
return useLiveQuery(
"status",
selector,
(client) => client.tasks.statuses(selector),
options,
);
}
+257
View File
@@ -0,0 +1,257 @@
import type { Ref } from "../core";
import { idOf, LiveError } from "../core";
import { toEpochMinuteString } from "../host/dates";
import type { WireCall } from "../host/perform";
import { createdRef, perform, performAll } from "../host/perform";
import { paragraph } from "../host/quill";
import { ArtifactWriteActionType as Action, PRIORITY_IDS } from "../host/wire";
import type { Session } from "../session";
import { resolve } from "../session";
import { applyPatch } from "../store";
import { syncChecklists } from "./checklists";
import { getTasks } from "./read";
import type { CreateTaskInput, Task, TaskPatch } from "./tasks";
/** Applies `patch` to the task optimistically, then through the host. */
export async function updateTask(
s: Session,
target: Ref<"task"> | string,
patch: TaskPatch,
): Promise<void> {
const task = await resolve(s, "task", target, getTasks);
await s.store.update(
task,
(current) => applyTaskPatch(current, patch),
async () => {
await performAll(s.host, taskPatchCalls(task, patch));
if (patch.checklists !== undefined) {
await syncChecklists(s.host, task, patch.checklists);
}
},
);
}
/** Creates the task, then adds any team assignees, which the create action itself cannot take. */
export async function createTask(
s: Session,
input: CreateTaskInput,
): Promise<Ref<"task">> {
const users = input.assignees?.filter((ref) => ref.kind === "user") ?? [];
const teams = input.assignees?.filter((ref) => ref.kind === "team") ?? [];
const outcome = await perform(s.host, Action.CreateTask, {
listId: idOf(input.list),
name: input.name,
...(input.parent ? { parentTaskId: idOf(input.parent) } : {}),
...(input.bodyMarkdown !== undefined
? { description: input.bodyMarkdown }
: {}),
...(input.status ? { status: input.status.name } : {}),
...(input.type !== undefined ? { typeId: idOf(input.type) } : {}),
...(input.priority ? { priority: PRIORITY_IDS[input.priority] } : {}),
...(input.dueAt ? { dueDate: toEpochMinuteString(input.dueAt) } : {}),
...(input.startAt ? { startDate: toEpochMinuteString(input.startAt) } : {}),
...(users.length > 0
? { assigneeUserIds: users.map((ref) => ref.id).join(",") }
: {}),
});
const created = createdRef("task", outcome, input.name);
for (const team of teams) {
await perform(s.host, Action.AddTaskGroupAssignee, {
taskId: created.id,
groupId: team.id,
});
}
await s.store.invalidate("task");
return created;
}
/** The ordered host calls that apply `patch` to `task`; checklists are synced apart by `syncChecklists`. */
export function taskPatchCalls(task: Task, patch: TaskPatch): WireCall[] {
const taskId = task.id;
const calls: WireCall[] = [];
if (patch.name !== undefined) {
calls.push([Action.SetTaskName, { taskId, name: patch.name }]);
}
if (patch.status !== undefined) {
calls.push([Action.SetTaskStatus, statusParams(taskId, patch.status)]);
}
if (patch.type !== undefined) {
calls.push([Action.SetTaskType, { taskId, typeId: idOf(patch.type) }]);
}
if (patch.priority !== undefined) {
calls.push([
Action.SetTaskPriority,
{
taskId,
priority:
patch.priority === null ? "none" : PRIORITY_IDS[patch.priority],
},
]);
}
if (patch.points !== undefined) {
calls.push([
Action.SetTaskPoints,
{ taskId, points: patch.points === null ? "none" : String(patch.points) },
]);
}
if (patch.dueAt !== undefined) {
calls.push(
patch.dueAt === null
? [Action.ClearTaskDueDate, { taskId }]
: [
Action.SetTaskDueDate,
{ taskId, dueDate: toEpochMinuteString(patch.dueAt) },
],
);
}
if (patch.startAt !== undefined) {
calls.push(
patch.startAt === null
? [Action.ClearTaskStartDate, { taskId }]
: [
Action.SetTaskStartDate,
{ taskId, startDate: toEpochMinuteString(patch.startAt) },
],
);
}
if (patch.parent !== undefined) {
calls.push([
Action.SetTaskParent,
{ taskId, parentTaskId: idOf(patch.parent) },
]);
}
if (patch.assignees !== undefined) {
const { added, removed } = diffById(task.assignees, patch.assignees);
for (const ref of added) {
calls.push(
ref.kind === "team"
? [Action.AddTaskGroupAssignee, { taskId, groupId: ref.id }]
: [Action.AddTaskAssignee, { taskId, assigneeUserId: ref.id }],
);
}
for (const ref of removed) {
calls.push(
ref.kind === "team"
? [Action.RemoveTaskGroupAssignee, { taskId, groupId: ref.id }]
: [Action.RemoveTaskAssignee, { taskId, assigneeUserId: ref.id }],
);
}
}
if (patch.watchers !== undefined) {
// The host follows and unfollows the viewer only; the refs' ids are not sent.
const { added, removed } = diffById(task.watchers, patch.watchers);
if (added.length > 0) {
calls.push([Action.FollowTask, { taskId }]);
}
if (removed.length > 0) {
calls.push([Action.UnfollowTask, { taskId }]);
}
}
if (patch.tags !== undefined) {
const current = new Set(task.tags.map((tag) => tag.name));
const next = new Set(patch.tags.map((tag) => tag.name));
for (const name of next) {
if (!current.has(name)) {
calls.push([Action.AddTaskTag, { taskId, tagName: name }]);
}
}
for (const name of current) {
if (!next.has(name)) {
calls.push([Action.RemoveTaskTag, { taskId, tagName: name }]);
}
}
}
if (patch.customFields !== undefined) {
const next = new Map(patch.customFields.map((field) => [field.id, field]));
for (const field of next.values()) {
calls.push([
Action.SetTaskCustomField,
{ taskId, fieldId: field.id, value: customFieldValueParam(field) },
]);
}
for (const field of task.customFields) {
if (!next.has(field.id)) {
calls.push([
Action.ClearTaskCustomField,
{ taskId, fieldId: field.id },
]);
}
}
}
if (patch.bodyMarkdown !== undefined) {
calls.push([
Action.SetTaskDescription,
{ taskId, markdownDescription: patch.bodyMarkdown },
]);
}
return calls;
}
/** Legacy statuses have no id of their own and are addressed by label. */
function statusParams(
taskId: string,
status: Ref<"status">,
): Record<string, string> {
return status.id && status.id !== status.name
? { taskId, statusId: status.id }
: { taskId, status: status.name };
}
function diffById<T extends Ref>(
current: T[],
next: T[],
): { added: T[]; removed: T[] } {
const currentIds = new Set(current.map((ref) => ref.id));
const nextIds = new Set(next.map((ref) => ref.id));
return {
added: next.filter((ref) => !currentIds.has(ref.id)),
removed: current.filter((ref) => !nextIds.has(ref.id)),
};
}
const READ_ONLY_FIELD_TYPES: ReadonlySet<Task["customFields"][number]["type"]> =
new Set(["users", "tasks", "list_relationship", "attachment"]);
/** The wire value of a field; labels take comma-separated option names. */
function customFieldValueParam({
type,
value,
}: Task["customFields"][number]): string {
if (READ_ONLY_FIELD_TYPES.has(type)) {
throw new LiveError(
"forbidden",
`Custom fields of type ${type} cannot be written yet.`,
);
}
return Array.isArray(value) ? value.join(",") : String(value);
}
/**
* The task as it will read back once the host has applied `patch`. A parent
* or type given by id alone has no name to show, and a new description shows
* as plain text, until the refetch.
*/
export function applyTaskPatch(task: Task, patch: TaskPatch): Task {
const { bodyMarkdown, parent, type, ...fields } = patch;
const next = applyPatch(task, fields);
if (bodyMarkdown !== undefined) {
next.body = paragraph(bodyMarkdown);
}
if (patch.status) {
next.status = { ...task.status, ...patch.status };
}
if (type !== undefined) {
next.type =
typeof type === "string"
? { kind: "taskType", id: type, name: "", url: "" }
: type;
}
if (parent && typeof parent !== "string") {
next.ancestors = [
...task.ancestors.filter((ref) => ref.kind !== "task"),
parent,
];
}
return next;
}
+240
View File
@@ -0,0 +1,240 @@
/** Cached data older than this is not painted. */
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** A page is closed once its things pass this many JSON characters. */
export const PAGE_BYTES = 500_000;
/** The cache is trimmed to about this many JSON characters. */
export const BUDGET_BYTES = 10_000_000;
/** Anything the store holds: an asset, or a query. */
export interface Thing {
kind: string;
id: string;
}
/** A query as a thing: when it landed, and what it lists by key or holds inline. */
export interface QueryThing extends Thing {
kind: "query";
/** The query key. */
id: string;
at: number;
/** The listed assets' keys, for asset kinds. */
ids?: string[];
/** The rows themselves, for the other kinds. */
rows?: object[];
}
export function keyOf(thing: Thing): string {
return `${thing.kind}#${thing.id}`;
}
function queryKeyOf(key: string): string {
return keyOf({ kind: "query", id: key });
}
export function isQuery(thing: Thing): thing is QueryThing {
return thing.kind === "query";
}
/** Roots (queries) and nodes (assets) are paged apart; the prefix sorts roots first on disk. */
const LANES = ["0r", "1n"] as const;
type Lane = (typeof LANES)[number];
function laneOf(page: string): Lane | undefined {
return LANES.find((lane) => page.startsWith(`${lane}:`));
}
/**
* One map of things keyed `kind#id`, tracking which page each is on and
* whether it changed since. Queries are the roots: an asset lives while a
* query lists it.
*/
export class Things {
private readonly map = new Map<string, Thing>();
private readonly pageOf = new Map<string, string>();
private readonly dirty = new Set<string>();
/** Per thing, `<json length>:<json hash>`, so identical content is recognized without keeping the JSON. */
private readonly sig = new Map<string, string>();
/** Per lane, the page new things go on: the newest one, until it is full. */
private readonly open = new Map<Lane, { key: string; bytes: number }>();
/** JSON characters on pages, the measure the budget is in. */
bytes = 0;
/** Bumped on every change, so a derived value can tell it is current. */
version = 0;
get(key: string): Thing | undefined {
return this.map.get(key);
}
query(key: string): QueryThing | undefined {
return this.map.get(queryKeyOf(key)) as QueryThing | undefined;
}
deleteQuery(key: string): void {
this.delete(queryKeyOf(key));
}
hasPage(thing: Thing): boolean {
return this.pageOf.has(keyOf(thing));
}
/**
* Puts a thing in the map and on a page. Identical content keeps the object
* already held and dirties nothing; new content dirties the thing's page.
*/
set(key: string, thing: Thing): void {
const json = JSON.stringify(thing);
if (this.map.has(key) && this.sig.get(key) === signature(json)) {
return;
}
this.bytes += json.length - this.pagedSize(key);
this.hold(key, thing, json);
let page = this.pageOf.get(key);
if (!page) {
page = this.place(isQuery(thing) ? "0r" : "1n", json.length);
this.pageOf.set(key, page);
}
this.dirty.add(page);
}
/** Holds a thing in memory only; it never reaches a page. */
hold(key: string, thing: Thing, json = JSON.stringify(thing)): void {
this.sig.set(key, signature(json));
this.map.set(key, thing);
this.version += 1;
}
private pagedSize(key: string): number {
return this.pageOf.has(key) ? sizeOf(this.sig.get(key)) : 0;
}
/** The open page of `lane`, or a new one once the open page is full. */
private place(lane: Lane, bytes: number): string {
let open = this.open.get(lane);
if (!open || open.bytes + bytes > PAGE_BYTES) {
open = { key: `${lane}:${pageStamp()}`, bytes: 0 };
this.open.set(lane, open);
}
open.bytes += bytes;
return open.key;
}
delete(key: string): void {
if (!this.map.delete(key)) {
return;
}
this.bytes -= this.pagedSize(key);
this.sig.delete(key);
this.version += 1;
const page = this.pageOf.get(key);
this.pageOf.delete(key);
if (page) {
this.dirty.add(page);
}
}
/** Drops every asset no query lists. */
collect(): void {
const listed = new Set<string>();
for (const thing of this.map.values()) {
if (isQuery(thing)) {
for (const id of thing.ids ?? []) {
listed.add(id);
}
}
}
for (const [key, thing] of this.map) {
if (!isQuery(thing) && !listed.has(key)) {
this.delete(key);
}
}
}
/**
* Drops the least recently used queries on disk that `inUse` does not
* claim, and what only they listed, until the pages fit `budget`.
* @returns The dropped queries.
*/
evict(budget: number, inUse: (query: QueryThing) => boolean): QueryThing[] {
const dropped: QueryThing[] = [];
while (this.bytes > budget) {
let oldest: QueryThing | undefined;
for (const [key, thing] of this.map) {
if (
isQuery(thing) &&
this.pageOf.has(key) &&
!inUse(thing) &&
(!oldest || thing.at < oldest.at)
) {
oldest = thing;
}
}
if (!oldest) {
break;
}
this.delete(keyOf(oldest));
this.collect();
dropped.push(oldest);
}
return dropped;
}
/**
* Adopts a page read from the cache: things the map does not hold yet, on
* the page they came from. A copy held elsewhere or an expired query is
* left out and dirties the page; the newest page of each lane stays open.
*/
seedPage(page: string, things: Thing[]): void {
const now = Date.now();
let bytes = 0;
for (const thing of things) {
const key = keyOf(thing);
const expired = isQuery(thing) && now - thing.at > MAX_AGE_MS;
if (this.map.has(key) || expired) {
this.dirty.add(page);
continue;
}
const json = JSON.stringify(thing);
this.hold(key, thing, json);
this.pageOf.set(key, page);
bytes += json.length;
}
this.bytes += bytes;
const lane = laneOf(page);
const open = lane && this.open.get(lane);
if (lane && (!open || open.key < page)) {
this.open.set(lane, { key: page, bytes });
}
}
/** The dirty pages with their things, an empty page meaning a deletion; clears the dirty set. */
takeDirtyPages(): Map<string, Thing[]> {
const pages = new Map<string, Thing[]>();
for (const page of this.dirty) {
pages.set(page, []);
}
for (const [key, page] of this.pageOf) {
pages.get(page)?.push(this.map.get(key)!);
}
this.dirty.clear();
return pages;
}
}
function signature(json: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < json.length; index += 1) {
hash ^= json.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `${json.length}:${hash >>> 0}`;
}
function sizeOf(signature: string | undefined): number {
return signature ? Number(signature.slice(0, signature.indexOf(":"))) : 0;
}
/** Page keys sort by creation, and two pages never collide, even across tabs. */
function pageStamp(): string {
const random = Math.random().toString(36).slice(2, 8);
return `${Date.now().toString(36).padStart(9, "0")}-${random}`;
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
LiveClient,
QueryStatus,
UseQueryOptions,
UseQueryResult,
} from "./core";
import { clickup } from "./client";
import { LiveError } from "./core";
import type { QueryKind } from "./store";
import { queryKey } from "./store";
/** The shortest `refetchInterval` honoured; a shorter one polls the host at this rate. */
const MIN_REFETCH_INTERVAL_MS = 10_000;
export function listOrGet<T, S extends object, R>(
api: { list(selector: S): Promise<T[]>; get(targets: R[]): Promise<T[]> },
input: S | R[],
): Promise<T[]> {
return Array.isArray(input) ? api.get(input) : api.list(input);
}
/** Every `use<Kind>` hook: one cached, shared query per kind and selector. */
export function useLiveQuery<T extends object>(
kind: QueryKind,
selector: object,
fetch: (client: LiveClient) => Promise<T[]>,
options: UseQueryOptions<T[]>,
): UseQueryResult<T[]> {
const client = options.client ?? clickup;
const { store } = client;
const enabled = options.enabled !== false;
const { cache } = options;
const key = queryKey(kind, selector);
const [, rerender] = useState(0);
const refetch = useCallback(
() =>
enabled
? store.fetch(key, true).then(
() => undefined,
() => undefined,
)
: Promise.resolve(),
[store, key, enabled],
);
useEffect(() => {
if (!enabled) {
return;
}
const unsubscribe = store.subscribe(
key,
kind,
() => fetch(client),
() => rerender((tick) => tick + 1),
{ cache },
);
store.fetch(key).catch(() => undefined);
return unsubscribe;
// The selector, and so the fetch, is fully determined by the key.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store, client, key, enabled, cache]);
const { refetchInterval } = options;
useEffect(() => {
if (!enabled || !refetchInterval) {
return;
}
const timer = setInterval(
() => {
if (document.visibilityState === "visible") {
store.fetch(key, true).catch(() => undefined);
}
},
Math.max(refetchInterval, MIN_REFETCH_INTERVAL_MS),
);
return () => clearInterval(timer);
}, [store, key, enabled, refetchInterval]);
const state = store.state<T>(key);
const error =
state.error === undefined
? null
: state.error instanceof LiveError
? state.error
: new LiveError("failed", String(state.error), { retryable: true });
const previous = useRef<T[]>();
if (state.data !== undefined) {
previous.current = state.data;
}
const placeholder =
state.data !== undefined || error
? undefined
: placeholderOf(options.placeholderData, previous.current);
const data = state.data ?? placeholder;
const status: QueryStatus = error
? "error"
: data !== undefined
? "success"
: "pending";
const isPending = status === "pending";
const isFetching =
state.fetching || (state.data === undefined && !error && enabled);
return {
data,
error,
status,
isPending,
isLoading: isPending && isFetching,
isFetching,
isRefetching: isFetching && !isPending,
isPlaceholderData: placeholder !== undefined,
isError: status === "error",
isSuccess: status === "success",
dataUpdatedAt: state.at,
refetch,
};
}
function placeholderOf<T>(
placeholderData: UseQueryOptions<T>["placeholderData"],
previous: T | undefined,
): T | undefined {
return typeof placeholderData === "function"
? (placeholderData as (previous: T | undefined) => T | undefined)(previous)
: placeholderData;
}
+193
View File
@@ -0,0 +1,193 @@
import * as z from "zod/v4-mini";
import en from "zod/v4/locales/en.js";
import type { AssetKind, Delta, IsoDate, Ref } from "./core";
import { ASSET_KINDS, idOf, LiveError } from "./core";
import { ARTIFACT_QUERY_MAX_LIMIT } from "./host/wire";
z.config({
...en(),
customError: (issue) => {
if (
issue.code === "invalid_type" &&
issue.input === undefined &&
issue.path?.length
) {
return "required";
}
if (issue.code === "too_small" && issue.origin === "string") {
return "must not be empty";
}
return undefined;
},
});
export type Schema<T> = z.ZodMiniType<T>;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Exactly<S extends z.ZodMiniType, T> =
Equal<z.output<S>, T> extends true
? unknown
: { "the schema's output differs from": T };
/**
* Pins a schema to the public type it validates: `schemaOf<Task>()(z.object(…))`
* compiles only when the schema accepts exactly the values of `Task`, so the
* two cannot drift apart.
*/
export function schemaOf<T>() {
return <S extends z.ZodMiniType>(schema: S & Exactly<S, T>): Schema<T> =>
schema as unknown as Schema<T>;
}
export const id = z.string().check(z.minLength(1, "Expected a non-empty id"));
/** ISO 8601 as `Date#toISOString()` writes it; a plain `YYYY-MM-DD` is the viewer's day. */
export const isoDate: Schema<IsoDate> = z.union(
[z.iso.datetime({ offset: true, local: true }), z.iso.date()],
{ error: "Expected an ISO 8601 date" },
);
export const delta: Schema<Delta> = z.object({
ops: z.array(z.record(z.string(), z.unknown())),
});
/** The fields every ref has; spread into an asset schema. */
export function refFields<K extends AssetKind>(kinds: readonly [K, ...K[]]) {
return {
kind: z.enum(kinds),
id,
name: z.string(),
url: z.string(),
image: z.optional(z.string()),
};
}
const HOOKS: Readonly<Record<AssetKind, string>> = {
space: "useSpaces",
folder: "useFolders",
list: "useLists",
status: "useStatuses",
customField: "useCustomFields",
task: "useTasks",
taskType: "useTaskTypes",
comment: "useComments",
user: "useUsers",
team: "useTeams",
inboxBundle: "useInbox",
reminder: "useReminders",
};
function sourceOf(kind: AssetKind): string {
return HOOKS[kind];
}
/** A ref of one of `kinds`, as its hook returns it; a full asset passes. */
export function ref<K extends AssetKind>(
kinds: readonly [K, ...K[]],
): Schema<Ref<K>> {
const fields = z.object(refFields(kinds));
return z.custom<Ref<K>>((value) => fields.safeParse(value).success, {
error: `Expected a ${either(kinds)} ref, from ${either(kinds.map(sourceOf))}`,
});
}
function either(words: readonly string[]): string {
return words.length > 1
? `${words.slice(0, -1).join(", ")} or ${words[words.length - 1]}`
: words[0]!;
}
export const anyRef = ref(ASSET_KINDS);
/** A ref of `kind`, or its bare id. */
export function refOrId<K extends AssetKind>(kind: K): Schema<Ref<K> | string> {
return z.union([id, ref([kind])], {
error: `Expected a ${kind} ref, from ${sourceOf(kind)}, or a ${kind} id`,
});
}
/** A selector's `limit`: a positive whole number up to the kind's cap. */
export function limit(max = ARTIFACT_QUERY_MAX_LIMIT) {
return z.optional(
z.int().check(z.positive(), z.maximum(max, `Expected at most ${max}`)),
);
}
/** What every selector takes besides its scope and filters. */
export const selection = {
limit: limit(),
};
const text = z.string().check(z.minLength(1, "Expected text to look for"));
/** A selector's `{ contains }` text filter; `max` caps the text where the host does. */
export function contains(max?: number) {
return z.optional(
z.strictObject({
contains: max === undefined ? text : text.check(z.maxLength(max)),
}),
);
}
/** A selector's `orderBy`: one of `fields`, with an optional direction. */
export function orderBy<F extends string>(fields: readonly [F, ...F[]]) {
return z.optional(
z.strictObject({
field: z.enum(fields),
direction: z.optional(z.enum(["asc", "desc"])),
}),
);
}
/** A selector below the workspace names a `parent` or an `ancestor`, never both; nothing lists everything. */
export const scoped = z.refine<{ parent?: unknown; ancestor?: unknown }>(
(selector) =>
(selector.parent !== undefined) !== (selector.ancestor !== undefined),
{ error: "Pass parent or ancestor, not both" },
);
/** The refs or ids a `get` takes, read as distinct ids in the order given. */
export function targets<K extends AssetKind>(kind: K): Schema<string[]> {
return z.pipe(
z.array(refOrId(kind)),
z.transform((refs) => [...new Set(refs.map(idOf))]),
);
}
/** `input` as `schema` reads it, or `invalid` naming every problem at once. */
export function check<T>(schema: Schema<T>, input: unknown): T {
const result = schema.safeParse(input);
if (result.success) {
return result.data;
}
throw new LiveError("invalid", describe(result.error.issues));
}
/** `value` as read from the host, or `failed` when it is not the `what` the kit promises. */
export function fromHost<T>(
schema: Schema<T>,
value: unknown,
what: string,
): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
throw new LiveError(
"failed",
`The host returned unexpected ${what}: ${describe(result.error.issues)}`,
);
}
function describe(issues: readonly z.core.$ZodIssue[]): string {
return issues
.map((issue) =>
issue.path.length > 0
? `${issue.path.join(".")}: ${issue.message}`
: issue.message,
)
.join("; ");
}