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.