- 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.
238 lines
9 KiB
JavaScript
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() {},
|
|
});
|
|
}
|