openpetswithchatandmcp/plugins/official/familiaros.focus-buddy/index.js
OpenPets Dev 6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar.
- Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages.
- Rename plugin IDs and directories from openpets.* to familiaros.*.
- Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration.
- Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents.
- Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings.
- Add Familiar naming feature: preference, settings input, tray menu display.
- Update assets and packaging config; all desktop tests pass.
2026-06-17 01:42:08 +00:00

238 lines
9 KiB
JavaScript

// Focus Buddy (familiaros.focus-buddy) — SDK v3 Pomodoro-style timer.
export const SCHEDULE_ID = "focus-buddy-session-end";
export const STORAGE_KEY = "session";
export const SHORT_BREAK_MS = 5 * 60_000;
export const LONG_BREAK_MS = 15 * 60_000;
const pinnedBubbles = new WeakMap();
function getPinnedBubble(ctx) {
return pinnedBubbles.get(ctx) ?? null;
}
function setPinnedBubble(ctx, handle) {
if (handle) pinnedBubbles.set(ctx, handle);
else pinnedBubbles.delete(ctx);
}
export function focusMs(config = {}) {
const minutes = [25, 45, 60].includes(Number(config.focusLength)) ? Number(config.focusLength) : 25;
return minutes * 60_000;
}
export function breakMs(completedFocusCount = 0) {
return completedFocusCount > 0 && completedFocusCount % 4 === 0 ? LONG_BREAK_MS : SHORT_BREAK_MS;
}
export function minutesLeft(session, now = Date.now()) {
const ms = session?.pausedRemainingMs ?? Math.max(0, (session?.endsAt ?? now) - now);
return Math.max(1, Math.ceil(ms / 60_000));
}
function active(session) {
return session && (session.mode === "focus" || session.mode === "break") && !session.ended;
}
async function getSession(ctx) {
const session = await ctx.storage.get(STORAGE_KEY);
return session && typeof session === "object" ? session : null;
}
async function saveSession(ctx, session) {
if (session) await ctx.storage.set(STORAGE_KEY, session);
else await ctx.storage.set(STORAGE_KEY, null);
await updateStatus(ctx, session);
return session;
}
async function config(ctx) {
return (await ctx.config.get()) ?? {};
}
function shouldSound(cfg) {
return cfg.breakStyle !== "gentle" && Boolean(cfg.sound);
}
async function updateStatus(ctx, session) {
if (!active(session)) {
await ctx.status.set({ text: ctx.t("status.idle"), tone: "info" });
return;
}
const mode = session.mode === "focus" ? ctx.t("mode.focus") : ctx.t("mode.break");
await ctx.status.set({ text: ctx.t("status.active", { mode, minutes: minutesLeft(session) }), tone: "info" });
}
async function scheduleEnd(ctx, session) {
await ctx.schedule.cancel(SCHEDULE_ID);
if (active(session) && !session.pausedRemainingMs) {
await ctx.schedule.once(SCHEDULE_ID, Math.max(1, session.endsAt - Date.now()), () => completeSession(ctx));
}
}
async function updatePinned(ctx, session) {
const pinnedBubble = getPinnedBubble(ctx);
if (!active(session)) {
if (pinnedBubble) {
try {
await pinnedBubble.dismiss();
} catch {}
}
setPinnedBubble(ctx, null);
return;
}
const text = ctx.t(session.pausedRemainingMs ? "bubble.paused" : "bubble.active", {
mode: session.mode === "focus" ? ctx.t("mode.focus") : ctx.t("mode.break"),
minutes: minutesLeft(session),
});
const actions = session.pausedRemainingMs
? [{ id: "resume", label: ctx.t("action.resume"), style: "primary" }, { id: "end", label: ctx.t("action.end") }]
: [
{ id: "pause", label: ctx.t("action.pause"), style: "primary" },
{ id: "end", label: ctx.t("action.end") },
...(session.mode === "focus" ? [{ id: "skip-break", label: ctx.t("action.skipToBreak") }] : []),
];
const spec = { text, tone: "info", sticky: true, pin: true, priority: "normal", actions };
if (pinnedBubble) {
try {
await pinnedBubble.update(spec);
return;
} catch {
setPinnedBubble(ctx, null);
}
}
const nextBubble = await ctx.ui.bubble(spec);
nextBubble.onAction((id) => handleAction(ctx, id));
nextBubble.onDismiss(() => {
if (getPinnedBubble(ctx)?.id === nextBubble.id) setPinnedBubble(ctx, null);
});
setPinnedBubble(ctx, nextBubble);
}
async function startMode(ctx, mode, durationMs, completedFocusCount) {
const now = Date.now();
const session = await saveSession(ctx, { mode, startedAt: now, endsAt: now + durationMs, pausedRemainingMs: null, completedFocusCount });
await scheduleEnd(ctx, session);
await updatePinned(ctx, session);
return session;
}
export async function startFocus(ctx) {
const current = await getSession(ctx);
return startMode(ctx, "focus", focusMs(await config(ctx)), current?.completedFocusCount ?? 0);
}
export async function startBreak(ctx, completedFocusCount) {
return startMode(ctx, "break", breakMs(completedFocusCount), completedFocusCount);
}
export async function pauseOrResume(ctx) {
const session = await getSession(ctx);
if (!active(session)) return startFocus(ctx);
if (session.pausedRemainingMs) {
session.endsAt = Date.now() + session.pausedRemainingMs;
session.pausedRemainingMs = null;
} else {
session.pausedRemainingMs = Math.max(1, session.endsAt - Date.now());
await ctx.schedule.cancel(SCHEDULE_ID);
}
await saveSession(ctx, session);
await scheduleEnd(ctx, session);
await updatePinned(ctx, session);
return session;
}
export async function endSession(ctx) {
await ctx.schedule.cancel(SCHEDULE_ID);
await saveSession(ctx, null);
await updatePinned(ctx, null);
}
export async function skipToBreak(ctx) {
const session = await getSession(ctx);
const count = (session?.completedFocusCount ?? 0) + (session?.mode === "focus" ? 1 : 0);
return startBreak(ctx, count);
}
async function focusComplete(ctx, session) {
const completedFocusCount = (session?.completedFocusCount ?? 0) + 1;
await saveSession(ctx, { ...session, mode: "complete", completedFocusCount });
await updatePinned(ctx, null);
const cfg = await config(ctx);
const alert = await ctx.ui.alert({
text: ctx.t("alert.focusComplete.text"),
indicator: { icon: ctx.assets.icon("focus"), label: ctx.t("alert.focusComplete.title"), tone: "success", color: "#059669", background: "#D1FAE5", borderColor: "#6EE7B7" },
tone: "success",
sound: shouldSound(cfg) ? cfg.sound : undefined,
dismissOn: ["action", "petClick", "click"],
actions: [{ id: "start-break", label: ctx.t("action.startBreak"), style: "primary" }, { id: "skip-break", label: ctx.t("action.skipBreak") }],
});
alert.onAction((id) => (id === "start-break" ? startBreak(ctx, completedFocusCount) : endSession(ctx)));
}
async function breakComplete(ctx, session) {
await saveSession(ctx, { ...session, mode: "complete" });
await updatePinned(ctx, null);
const cfg = await config(ctx);
const alert = await ctx.ui.alert({
text: ctx.t("alert.breakComplete.text"),
indicator: { icon: ctx.assets.icon("focus"), label: ctx.t("alert.breakComplete.title"), tone: "info", color: "#4F46E5", background: "#E0E7FF", borderColor: "#A5B4FC" },
tone: "info",
sound: shouldSound(cfg) ? cfg.sound : undefined,
dismissOn: ["action", "petClick", "click"],
actions: [{ id: "start-focus", label: ctx.t("action.startFocus"), style: "primary" }, { id: "done", label: ctx.t("action.done") }],
});
alert.onAction((id) => (id === "start-focus" ? startFocus(ctx) : endSession(ctx)));
}
export async function completeSession(ctx) {
await ctx.schedule.cancel(SCHEDULE_ID);
const session = await getSession(ctx);
if (!active(session)) return;
if (session.mode === "focus") await focusComplete(ctx, session);
else await breakComplete(ctx, session);
}
export async function reconcile(ctx) {
await ctx.schedule.cancel(SCHEDULE_ID);
const session = await getSession(ctx);
if (!active(session)) return updateStatus(ctx, null);
if (session.pausedRemainingMs || session.endsAt > Date.now()) {
await scheduleEnd(ctx, session);
await updatePinned(ctx, session);
return;
}
if (session.mode === "focus") await focusComplete(ctx, session);
else {
await saveSession(ctx, null);
await updatePinned(ctx, null);
await ctx.pet.speak(ctx.t("speech.breakOver"));
}
}
async function showStatus(ctx) {
const session = await getSession(ctx);
if (!active(session)) return ctx.pet.speak(ctx.t("speech.idle"));
await updatePinned(ctx, session);
}
async function handleAction(ctx, id) {
if (id === "pause" || id === "resume") return pauseOrResume(ctx);
if (id === "end") return endSession(ctx);
if (id === "skip-break") return skipToBreak(ctx);
}
export function register(FamiliarOSPlugin) {
FamiliarOSPlugin.register({
async start(ctx) {
await reconcile(ctx);
const focusIcon = ctx.assets.icon("focus");
await ctx.commands.register({ id: "start-focus", title: "$t:command.startFocus.title", description: "$t:command.startFocus.description", icon: focusIcon }, () => startFocus(ctx));
await ctx.commands.register({ id: "pause-resume", title: "$t:command.pauseResume.title", description: "$t:command.pauseResume.description", icon: focusIcon }, () => pauseOrResume(ctx));
await ctx.commands.register({ id: "end-session", title: "$t:command.endSession.title", description: "$t:command.endSession.description", icon: focusIcon }, () => endSession(ctx));
await ctx.commands.register({ id: "skip-to-break", title: "$t:command.skipToBreak.title", description: "$t:command.skipToBreak.description", icon: focusIcon }, () => skipToBreak(ctx));
await ctx.commands.register({ id: "show-status", title: "$t:command.showStatus.title", description: "$t:command.showStatus.description", icon: focusIcon }, () => showStatus(ctx));
},
async stop() {},
});
}