Vectrum
Sign In
70 lines · 2.5 KB · TypeScript
"use client";

import { createContext, use, useCallback, useMemo, useState, type ReactNode } from "react";
import { useToast } from "@unlocalhosted/metalui";
import { createLabelAction } from "@/app/actions/issues";
import { LABEL_COLORS, type IssueLabel, type Member } from "@/lib/issue-meta";

type Tone = "info" | "success" | "error";

type Tracker = {
  owner: string;
  name: string;
  base: string;
  members: Member[];
  labels: IssueLabel[];
  viewerId: number | null;
  /** Push access: may set priority, assignee, labels, estimates and reorder. */
  canTriage: boolean;
  /** Today as YYYY-MM-DD, from the server, for overdue highlighting. */
  today: string;
  createLabel: (name: string) => Promise<IssueLabel | null>;
  /** The result of the person's own action, on MetalUI's toast deck (errors stay until dismissed). */
  notify: (message: string, options?: { sub?: string; tone?: Tone }) => void;
};

const TrackerContext = createContext<Tracker | null>(null);

export function useTracker() {
  const tracker = use(TrackerContext);
  if (!tracker) throw new Error("useTracker must be used inside <TrackerProvider>");
  return tracker;
}

export function TrackerProvider({
  children,
  labels: serverLabels,
  ...rest
}: Omit<Tracker, "createLabel" | "notify"> & { children: ReactNode }) {
  const { owner, name } = rest;
  const toast = useToast();
  const [created, setCreated] = useState<IssueLabel[]>([]);

  const notify = useCallback<Tracker["notify"]>(
    (message, options) =>
      void toast.show({ title: message, sub: options?.sub, tone: options?.tone === "info" || !options?.tone ? "default" : options.tone }),
    [toast],
  );

  // Labels created from a picker are usable right away, before the page data refreshes.
  const labels = useMemo(() => {
    const ids = new Set(serverLabels.map((l) => l.id));
    return [...serverLabels, ...created.filter((l) => !ids.has(l.id))].sort((a, b) => a.name.localeCompare(b.name));
  }, [serverLabels, created]);

  const createLabel = useCallback(
    async (labelName: string) => {
      const color = LABEL_COLORS[labels.length % LABEL_COLORS.length];
      const result = await createLabelAction(owner, name, { name: labelName, color });
      if (result.error || !result.label) {
        notify(result.error ?? "Couldn't create the label.", { tone: "error" });
        return null;
      }
      setCreated((c) => [...c, result.label!]);
      return result.label;
    },
    [owner, name, labels.length, notify],
  );

  return <TrackerContext value={{ ...rest, labels, createLabel, notify }}>{children}</TrackerContext>;
}