Ada Extensions

Examples

Four complete extensions: weather, media controls, pomodoro and GitHub pull requests.

Each example runs as the manifest.json and src/index.ts of a folder made with adanotch init. All of them have been run with Adanotch's test kit.

Weather

fetch, settings, the last good answer while offline, and a chart. Uses Open-Meteo, which needs no key.

const { activity, ui } = AdanotchKit;

interface Settings {
  latitude: number;
  longitude: number;
  city: string;
}

interface Forecast {
  current: { temperature_2m: number; weather_code: number };
  daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[] };
}

function symbol(code: number): string {
  if (code === 0) return "sun.max.fill";
  if (code <= 3) return "cloud.sun.fill";
  if (code <= 48) return "cloud.fog.fill";
  if (code <= 67) return "cloud.rain.fill";
  if (code <= 77) return "cloud.snow.fill";
  return "cloud.bolt.rain.fill";
}

activity<Settings>({
  async update(ctx) {
    const url = new URL("https://api.open-meteo.com/v1/forecast");
    url.searchParams.set("latitude", String(ctx.settings.latitude));
    url.searchParams.set("longitude", String(ctx.settings.longitude));
    url.searchParams.set("current", "temperature_2m,weather_code");
    url.searchParams.set("daily", "temperature_2m_max,temperature_2m_min");
    url.searchParams.set("timezone", "auto");

    let data: Forecast;
    let stale = false;
    try {
      data = await ctx.network.fetchJSON<Forecast>(url.toString());
      ctx.storage.set("last", data);
    } catch (error) {
      const last = ctx.storage.get<Forecast>("last");
      if (!last || !(error instanceof NetworkError || error instanceof TimeoutError)) throw error;
      data = last;
      stale = true;
    }

    const temp = Math.round(data.current.temperature_2m);
    const highs = data.daily.temperature_2m_max;
    return {
      compact: {
        leading: ui.icon(symbol(data.current.weather_code)),
        trailing: ui.text(`${temp}°`, { tone: stale ? "secondary" : "default" }),
      },
      expanded: ui.stack([
        ui.stack(
          [
            ui.icon(symbol(data.current.weather_code), { tone: "accent" }),
            ui.text(ctx.settings.city, { style: "title" }),
            ui.spacer(),
            ui.text(`${temp}°`, { style: "display" }),
          ],
          { axis: "h", spacing: 8, align: "center" },
        ),
        ui.chart(highs, { style: "bar", tone: "warning" }),
        stale && ui.text("Offline; last known value", { style: "caption", tone: "secondary" }),
      ]),
    };
  },
});

Now playing

An extension driven by events: media.change, media controls and refresh: manual. Adanotch's Music module must be on.

const { activity, ui, media, events } = AdanotchKit;

activity({
  setup(ctx) {
    events.on("media.change", () => ctx.refresh());
  },

  async update() {
    const track = await media.nowPlaying();
    if (!track) {
      return { compact: null, expanded: ui.text("Nothing is playing", { tone: "secondary" }) };
    }
    const percent = track.duration > 0 ? (track.elapsed / track.duration) * 100 : 0;
    return {
      compact: {
        leading: ui.icon(track.isPlaying ? "waveform" : "pause.fill", { tone: "accent" }),
        trailing: ui.progress(percent, { tone: "accent" }),
      },
      expanded: ui.stack([
        ui.text(track.title, { style: "title" }),
        ui.text(`${track.artist} · ${track.album}`, { tone: "secondary" }),
        ui.progress(percent),
        ui.stack(
          [
            ui.button("", "previous", { icon: "backward.fill" }),
            ui.button("", "toggle", { icon: track.isPlaying ? "pause.fill" : "play.fill", primary: true }),
            ui.button("", "next", { icon: "forward.fill" }),
            ui.spacer(),
            ui.button("+30 sn", "seek", { value: Math.min(track.elapsed + 30, track.duration) }),
          ],
          { axis: "h", spacing: 8 },
        ),
      ]),
    };
  },

  async onAction(action, _ctx, value) {
    if (action === "previous") await media.previous();
    if (action === "toggle") await media.toggle();
    if (action === "next") await media.next();
    if (action === "seek") await media.seek(Number(value));
  },
});

Pomodoro

Timers, a ui.timer counting on its own beside the notch, island notifications, persistent state and Shortcuts commands (adanotch ext local.you.pomodoro start).

const { activity, ui, events, island, notify } = AdanotchKit;

interface Settings {
  focusMinutes: number;
  breakMinutes: number;
}

type Phase = "idle" | "focus" | "break";
interface State {
  phase: Phase;
  endsAt: number | null;
  done: number;
}

let timer: number | undefined;

activity<Settings>({
  setup(ctx) {
    events.on("command", ({ name }) => {
      if (name === "start") start(ctx, "focus");
      if (name === "stop") stop(ctx);
    });
    // If Adanotch restarted, carry on with the running round.
    const state = load(ctx);
    if (state.endsAt && state.endsAt > Date.now()) schedule(ctx, state.endsAt);
  },

  update(ctx) {
    const { phase, endsAt, done } = load(ctx);
    const running = phase !== "idle" && endsAt !== null;
    return {
      compact: running
        ? { leading: ui.icon(phase === "focus" ? "brain.head.profile" : "cup.and.saucer"), trailing: ui.timer(endsAt) }
        : { leading: ui.icon("timer", { tone: "secondary" }) },
      expanded: ui.stack([
        running
          ? ui.timer(endsAt, { textStyle: "display", tone: phase === "focus" ? "accent" : "success" })
          : ui.text("Ready", { style: "display", tone: "secondary" }),
        ui.text(`Rounds today: ${done}`, { style: "caption", tone: "secondary" }),
        ui.stack(
          [
            running
              ? ui.button("Stop", "stop", { icon: "stop.fill" })
              : ui.button(`Focus ${ctx.settings.focusMinutes} min`, "start", { icon: "play.fill", primary: true }),
          ],
          { axis: "h" },
        ),
      ]),
    };
  },

  onAction(action, ctx) {
    if (action === "start") start(ctx, "focus");
    if (action === "stop") stop(ctx);
  },
});

function load(ctx: Adanotch.Context<Settings>): State {
  return ctx.storage.get<State>("state") ?? { phase: "idle", endsAt: null, done: 0 };
}

function save(ctx: Adanotch.Context<Settings>, state: State) {
  ctx.storage.set("state", state);
}

function start(ctx: Adanotch.Context<Settings>, phase: Exclude<Phase, "idle">) {
  const minutes = phase === "focus" ? ctx.settings.focusMinutes : ctx.settings.breakMinutes;
  const endsAt = Date.now() + minutes * 60_000;
  save(ctx, { ...load(ctx), phase, endsAt });
  schedule(ctx, endsAt);
  ctx.refresh();
}

function stop(ctx: Adanotch.Context<Settings>) {
  clearTimeout(timer);
  save(ctx, { ...load(ctx), phase: "idle", endsAt: null });
  island.hide("pomodoro");
  ctx.refresh();
}

function schedule(ctx: Adanotch.Context<Settings>, endsAt: number) {
  clearTimeout(timer);
  timer = setTimeout(() => finish(ctx), endsAt - Date.now());
}

function finish(ctx: Adanotch.Context<Settings>) {
  const state = load(ctx);
  if (state.phase === "focus") {
    save(ctx, { ...state, done: state.done + 1 });
    notify.island({ text: "Time for a break", icon: "cup.and.saucer", tone: "success", duration: 5 });
    start(ctx, "break");
  } else {
    notify.island({ text: "Break is over", icon: "brain.head.profile", tone: "accent" });
    stop(ctx);
  }
}

GitHub pull requests

A token kept in the Keychain (a secret setting), authenticated fetch, a clickable list and open().

const { activity, ui, secrets, open } = AdanotchKit;

interface Settings {
  query: string;
}

interface SearchResult {
  total_count: number;
  items: {
    number: number;
    title: string;
    html_url: string;
    draft?: boolean;
    repository_url: string;
    user: { login: string; avatar_url: string };
  }[];
}

activity<Settings>({
  async update(ctx) {
    const token = await secrets.get("token");
    if (!token) {
      return {
        compact: { leading: ui.icon("arrow.triangle.pull", { tone: "secondary" }) },
        expanded: ui.markdown("Enter a **GitHub token** in Settings › Extensions."),
      };
    }

    const url = new URL("https://api.github.com/search/issues");
    url.searchParams.set("q", `is:pr is:open ${ctx.settings.query}`);
    url.searchParams.set("per_page", "20");

    const res = await fetch(url, {
      headers: {
        authorization: `Bearer ${token}`,
        accept: "application/vnd.github+json",
        "x-github-api-version": "2022-11-28",
      },
    });
    if (res.status === 401) {
      return { expanded: ui.text("Invalid token", { tone: "danger" }) };
    }
    if (!res.ok) throw new Error(`GitHub ${res.status}`);
    const { total_count, items } = await res.json<SearchResult>();

    return {
      compact: {
        leading: ui.icon("arrow.triangle.pull"),
        trailing: ui.badge(String(total_count), { tone: total_count > 0 ? "accent" : "secondary" }),
      },
      expanded: ui.scroll(
        ui.list(
          items.map((pr) => ({
            title: pr.title,
            subtitle: `${pr.repository_url.split("/").slice(-2).join("/")} #${pr.number} · ${pr.user.login}`,
            icon: pr.draft ? "pencil.circle" : "arrow.triangle.pull",
            tone: pr.draft ? ("secondary" as const) : ("default" as const),
            action: "open",
            value: pr.html_url,
          })),
        ),
        { maxHeight: 280 },
      ),
    };
  },

  async onAction(action, _ctx, value) {
    if (action === "open" && typeof value === "string") await open(value);
  },
});

For the token, create a fine-grained token; read access to pull requests is enough.

On this page