tasks-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 9b0d0f7b9a93
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone f6f2112e77bc8c011e38c83a
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/lib/board.ts
// Live board state and every write the app makes. Reads are live queries, so changes from anyone
// on the board show up right away. Card edits show at once on this screen and are undone if the
// write is refused.
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./bounded";
import { rememberBoard, forgetBoard } from "./mine";
import { toast } from "../ui";
import {
  seedWrites, chunk, randomId, posBetween, tooClose, byPos, parseIds, COLOR_IDS,
  type Board, type BoardSeed, type Card, type Label, type List, type Member, type Write,
} from "../../functions/lib/tasks";

type Row<T> = T & { id: string };
export type CardPatch = Partial<Omit<Card, "id" | "createdBy" | "createdAt">>;

const NOW = () => serverTimestamp();

/** Makes a board from a description in as many batches as it needs. The first batch holds the board, this device's join and ownership, and the admin key. */
export async function createBoard(seed: BoardSeed): Promise<{ boardId: string; key: string }> {
  const uid = await me();
  const boardId = randomId(20);
  const key = randomId(24);
  const { writes } = seedWrites(boardId, seed, uid, NOW());
  // A batch holds at most 100 writes, and the first one also carries the board, join, owner and key.
  const batches = [writes.slice(0, 96), ...chunk(writes.slice(96), 100)];
  await setMany([
    { path: `boards/${boardId}`, document: { title: seed.title, archived: false, createdBy: uid, createdAt: NOW() } },
    { path: `boards/${boardId}/joins/${uid}`, document: { at: NOW() } },
    { path: `boards/${boardId}/owners/${uid}`, document: { at: NOW() } },
    { path: `boardkeys/${boardId}`, document: { key } },
    ...(batches.shift() || []),
  ]);
  for (const b of batches) await setMany(b);
  rememberBoard({ id: boardId, title: seed.title, key });
  return { boardId, key };
}

/** Joins the board on this device (knowing the id is the permission). Returns false when the board does not exist or this device was removed. */
export async function joinBoard(boardId: string): Promise<{ uid: string; ok: boolean; removed?: boolean }> {
  const uid = await me();
  if (await get(`boards/${boardId}/joins/${uid}`)) return { uid, ok: true };
  try {
    await set(`boards/${boardId}/joins/${uid}`, { at: NOW() });
    return { uid, ok: true };
  } catch {
    const blocked = await get(`boards/${boardId}/blocks/${uid}`).catch(() => null);
    return { uid, ok: false, removed: Boolean(blocked) };
  }
}

/** Presents the admin key on this device. True when this device is (now) an owner. */
export async function claimBoard(boardId: string, uid: string, key: string): Promise<boolean> {
  if (await get(`boards/${boardId}/owners/${uid}`)) return true;
  try {
    await setMany([
      { path: `boardclaims/${boardId}/c/${uid}`, document: { key } },
      { path: `boards/${boardId}/owners/${uid}`, document: { at: NOW() } },
    ]);
    return true;
  } catch {
    return false;
  }
}

async function allIds(path: string): Promise<string[]> {
  const ids: string[] = [];
  let cursor: string | undefined;
  for (let page = 0; page < 50; page++) {
    const res = (await get(path, { limit: 1000, ...(cursor ? { cursor } : {}) })) as { data?: { id?: string; _id?: string }[]; nextCursor?: string } | null;
    for (const r of res?.data || []) ids.push(String(r.id ?? String(r._id ?? "").split("/").pop()));
    cursor = res?.nextCursor || undefined;
    if (!cursor) break;
  }
  return ids;
}

export function useBoardState(boardId: string, uid: string) {
  const base = `boards/${boardId}`;
  const { data: board } = useQuery(base) as { data: Board | null | undefined };
  const { data: listRows } = useQuery(`${base}/lists`, { limit: 1000 }) as { data: Row<List>[] | undefined };
  // Live queries return 100 rows unless asked for more, and 1,000 at most: open cards only; archived ones load on demand.
  const { data: cardRows } = useQuery(`${base}/cards`, { limit: 1000, filter: { archived: false } }) as { data: Row<Card>[] | undefined };
  const { data: labelRows } = useQuery(`${base}/labels`, { limit: 1000 }) as { data: Row<Label>[] | undefined };
  const { data: memberRows } = useQuery(`${base}/members`, { limit: 1000 }) as { data: Row<Member>[] | undefined };
  const { data: join } = useQuery(`${base}/joins/${uid}`) as { data: { memberId?: string | null } | null | undefined };
  const { data: owner } = useQuery(`${base}/owners/${uid}`) as { data: { at: number } | null | undefined };

  const [patches, setPatches] = useState<Record<string, CardPatch>>({});
  const patchesRef = useRef(patches);
  patchesRef.current = patches;
  const lastAdded = useRef<Record<string, number>>({});

  // A patch goes away once the live card shows the same values.
  useEffect(() => {
    if (!cardRows) return;
    const done = Object.keys(patchesRef.current).filter((id) => {
      const live = cardRows.find((c) => c.id === id);
      const p = patchesRef.current[id];
      return live && Object.entries(p).every(([k, v]) => (live as any)[k] === v);
    });
    if (done.length) setPatches((p) => Object.fromEntries(Object.entries(p).filter(([id]) => !done.includes(id))));
  }, [cardRows]);

  const lists = useMemo(() => [...(listRows || [])].sort(byPos), [listRows]);
  const cards = useMemo(() => (cardRows || []).map((c) => (patches[c.id] ? { ...c, ...patches[c.id] } : c)).filter((c) => !c.archived).sort(byPos), [cardRows, patches]);
  const labels = useMemo(() => [...(labelRows || [])].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)), [labelRows]);
  const members = useMemo(() => [...(memberRows || [])].sort((a, b) => a.name.localeCompare(b.name)), [memberRows]);
  const openLists = useMemo(() => lists.filter((l) => !l.archived), [lists]);
  const meId = join?.memberId && members.some((m) => m.id === join.memberId && !m.removed) ? join.memberId : null;
  const myMember = members.find((m) => m.id === meId) || null;

  const fail = (err: unknown) => toast(explain(err));

  const patchCard = (id: string, fields: CardPatch, extra: Write[] = []) => {
    setPatches((p) => ({ ...p, [id]: { ...p[id], ...fields } }));
    const drop = () => setPatches((p) => {
      const { [id]: _gone, ...rest } = p;
      return rest;
    });
    const op = extra.length ? setMany([...extra, { path: `${base}/cards/${id}`, document: fields }]) : set(`${base}/cards/${id}`, fields);
    return op.then(() => { window.setTimeout(drop, 4000); }, (err) => { drop(); fail(err); });
  };

  const actions = {
    updateCard: (id: string, fields: CardPatch) => patchCard(id, fields),

    /** Moves a card to `index` among the open cards of a list (index counts without the card itself). */
    moveCard(id: string, listId: string, index: number) {
      const target = cards.filter((c) => c.listId === listId && c.id !== id);
      const i = Math.max(0, Math.min(index, target.length));
      const before = target[i - 1]?.pos;
      const after = target[i]?.pos;
      if (before != null && after != null && tooClose(before, after)) {
        const extra = target.map((c, j) => ({ path: `${base}/cards/${c.id}`, document: { pos: (j + (j >= i ? 2 : 1)) * 1024 } }));
        return patchCard(id, { listId, pos: (i + 1) * 1024 }, extra);
      }
      return patchCard(id, { listId, pos: posBetween(before, after) });
    },

    async addCard(listId: string, title: string, extra: CardPatch = {}, at: "top" | "bottom" = "bottom") {
      const inList = cards.filter((c) => c.listId === listId);
      // Cards added quickly in a row may not be back from the server yet, so remember the last place used.
      const last = Math.max(inList[inList.length - 1]?.pos ?? -Infinity, lastAdded.current[listId] ?? -Infinity);
      const pos = at === "top" ? posBetween(undefined, inList[0]?.pos) : posBetween(Number.isFinite(last) ? last : undefined, undefined);
      if (at === "bottom") lastAdded.current[listId] = pos;
      const id = randomId(12);
      try {
        await set(`${base}/cards/${id}`, {
          listId, title, description: "", pos, due: "", done: false, labels: "[]", assignees: "[]", checklist: "[]", cover: "", archived: false,
          ...extra, createdBy: uid, createdAt: NOW(),
        });
        return id;
      } catch (err) {
        fail(err);
        return null;
      }
    },

    async deleteCard(id: string) {
      try {
        // The card's comments go with it, 100 writes a batch with the card first (the policy lets anyone on the board delete comments of a deleted card).
        const comments = ((await get(`${base}/comments`, { filter: { cardId: id }, limit: 1000 })) as { data?: { id: string }[] } | null)?.data?.map((c) => c.id) ?? [];
        for (const b of chunk([`${base}/cards/${id}`, ...comments.map((c) => `${base}/comments/${c}`)], 100)) await setMany(b.map((path) => ({ path, document: null })));
        toast("Card deleted.");
      } catch (err) {
        fail(err);
      }
    },

    async addList(title: string) {
      const last = Math.max(openLists[openLists.length - 1]?.pos ?? -Infinity, lastAdded.current[""] ?? -Infinity);
      const pos = posBetween(Number.isFinite(last) ? last : undefined, undefined);
      lastAdded.current[""] = pos;
      try {
        await set(`${base}/lists/${randomId(10)}`, { title, pos, archived: false, createdAt: NOW() });
      } catch (err) { fail(err); }
    },

    updateList: (id: string, fields: Partial<Pick<List, "title" | "archived" | "pos">>) => set(`${base}/lists/${id}`, fields).catch(fail),

    moveList(id: string, index: number) {
      const others = openLists.filter((l) => l.id !== id);
      const i = Math.max(0, Math.min(index, others.length));
      const before = others[i - 1]?.pos;
      const after = others[i]?.pos;
      if (before != null && after != null && tooClose(before, after)) {
        return setMany([
          ...others.map((l, j) => ({ path: `${base}/lists/${l.id}`, document: { pos: (j + (j >= i ? 2 : 1)) * 1024 } })),
          { path: `${base}/lists/${id}`, document: { pos: (i + 1) * 1024 } },
        ]).catch(fail);
      }
      return set(`${base}/lists/${id}`, { pos: posBetween(before, after) }).catch(fail);
    },

    async addLabel(name: string, color?: string) {
      const id = randomId(10);
      try {
        await set(`${base}/labels/${id}`, { name, color: color || COLOR_IDS[labels.length % COLOR_IDS.length], createdAt: NOW() });
        return id;
      } catch (err) { fail(err); return null; }
    },
    updateLabel: (id: string, fields: Partial<Pick<Label, "name" | "color">>) => set(`${base}/labels/${id}`, fields).catch(fail),
    deleteLabel: (id: string) => set(`${base}/labels/${id}`, null).catch(fail),

    async addMember(name: string) {
      const clash = members.find((m) => !m.removed && m.name.toLowerCase() === name.toLowerCase());
      if (clash) return clash.id;
      const id = randomId(10);
      try {
        await set(`${base}/members/${id}`, { name, removed: false, createdAt: NOW() });
        return id;
      } catch (err) { fail(err); return null; }
    },
    renameMember: (id: string, name: string) => set(`${base}/members/${id}`, { name }).catch(fail),

    /** Says which person this device is, so comments carry the name and the owner can see who has access. */
    setMe: (memberId: string | null) => set(`${base}/joins/${uid}`, { memberId }).catch(fail),

    async addComment(cardId: string, text: string, name: string) {
      try {
        await set(`${base}/comments/${randomId(14)}`, { cardId, text, by: uid, name, createdAt: NOW() });
        return true;
      } catch (err) { fail(err); return false; }
    },
    editComment: (id: string, text: string) => set(`${base}/comments/${id}`, { text, editedAt: NOW() }).catch(fail),
    deleteComment: (id: string) => set(`${base}/comments/${id}`, null).catch(fail),

    // Owner actions (the admin link). The policy refuses them for anyone else.
    renameBoard: (title: string) => set(base, { title }).then(() => rememberBoard({ id: boardId, title })),
    setBoardArchived: (archived: boolean) => set(base, { archived }),

    /** Removes a name from the board, and every device that picked it. Those devices cannot open the board again. */
    async removeMember(memberId: string, joins: { id: string; memberId?: string | null }[]) {
      const devices = joins.filter((j) => j.memberId === memberId && j.id !== uid);
      await setMany([
        { path: `${base}/members/${memberId}`, document: { removed: true } },
        ...devices.map((j) => ({ path: `${base}/joins/${j.id}`, document: null })),
        ...devices.map((j) => ({ path: `${base}/blocks/${j.id}`, document: { at: NOW() } })),
      ]);
      return devices.length;
    },
    async removeDevice(userId: string) {
      await setMany([{ path: `${base}/joins/${userId}`, document: null }, { path: `${base}/blocks/${userId}`, document: { at: NOW() } }]);
    },
    unblock: (userId: string) => set(`${base}/blocks/${userId}`, null),

    /** Deletes the board and everything on it. The board goes first, so nothing on it can be read from that moment.
     * It goes on its own, before any device's access, so open pages still receive the delete and say so. */
    async deleteBoard() {
      const children: string[] = [];
      for (const c of ["cards", "comments", "lists", "labels", "members", "joins", "blocks", "owners"]) {
        for (const id of await allIds(`${base}/${c}`)) if (!((c === "joins" || c === "owners") && id === uid)) children.push(`${base}/${c}/${id}`);
      }
      await setMany([{ path: base, document: null }]);
      for (const b of chunk(children, 100)) await setMany(b.map((path) => ({ path, document: null })));
      await setMany([{ path: `${base}/joins/${uid}`, document: null }, { path: `${base}/owners/${uid}`, document: null }]);
      forgetBoard(boardId);
    },
  };

  const ready = board !== undefined && listRows !== undefined && cardRows !== undefined && labelRows !== undefined && memberRows !== undefined;
  return {
    boardId, uid, base, ready, board, lists, openLists, cards, labels, members, meId, myMember,
    isOwner: Boolean(owner), actions,
    labelOf: (id: string) => labels.find((l) => l.id === id),
    memberOf: (id: string) => members.find((m) => m.id === id),
    cardLabels: (c: Card) => parseIds(c.labels).map((id) => labels.find((l) => l.id === id)).filter((l): l is Row<Label> => Boolean(l)),
    cardPeople: (c: Card) => parseIds(c.assignees).map((id) => members.find((m) => m.id === id)).filter((m): m is Row<Member> => Boolean(m) && !m!.removed),
  };
}

export type BoardState = ReturnType<typeof useBoardState>;
export const BoardContext = createContext<BoardState | null>(null);
export function useBoard(): BoardState {
  const b = useContext(BoardContext);
  if (!b) throw new Error("useBoard outside a board");
  return b;
}

/** Archived cards, loaded only when someone opens the archive. */
export function useArchivedCards(boardId: string, on: boolean) {
  const { data } = useQuery(on ? `boards/${boardId}/cards` : null, { limit: 1000, filter: { archived: true } }) as { data: Row<Card>[] | undefined };
  return data;
}

Change history

Point OpenApps links to openapps.free
9b0d0f7b9a · 18 hours ago
Bind agent address in policy
25f84633c0 · 19 hours ago
Use OpenApps service URLs and add contribution strip
07519eb348 · 19 hours ago
Bounded source sync
3296872001 · 1 day ago
Bounded source sync
ac8d2f5d18 · 1 day ago
Bounded source sync
2a5b08a001 · 1 day ago
Bounded source sync
d2242ddf34 · 1 day ago