Add timer tags and optional start description

Buttons can carry a Timer Tags select value and prompt for an optional
description before starting. Tag options and the prompt toggle are
configured per-button in the property inspector; startTimer writes the
tag to the Timer Tags select property and the description to the
Description rich_text property when set.
This commit is contained in:
pdmarf
2026-10-08 09:48:03 +01:00
parent 0956b28864
commit c50ee5e822
5 changed files with 201 additions and 39 deletions
+49 -12
View File
@@ -101,7 +101,7 @@ import streamDeck, {
SingletonAction,
WillAppearEvent,
} from "@elgato/streamdeck";
import { fetchProjects, fetchUsers, startTimer, stopTimer } from "./notion.js";
import { fetchProjects, fetchTagOptions, fetchUsers, startTimer, stopTimer } from "./notion.js";
interface GlobalSettings {
notionToken: string;
@@ -115,6 +115,9 @@ interface GlobalSettings {
interface TimerSettings {
projectId: string;
projectName: string;
tag?: string;
tagEnabled?: boolean;
askDescription?: boolean;
}
const HARDCODED = {
@@ -155,14 +158,18 @@ async function sendProjectsToPI(tokenOverride?: string): Promise<void> {
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION });
return;
}
const [projects, usersResult] = await Promise.all([
const [projects, usersResult, tagsResult] = await Promise.all([
fetchProjects(token, global.projectsDbId),
fetchUsers(token).catch((err) => {
streamDeck.logger.error("Failed to fetch users:", err);
return [];
}),
fetchTagOptions(token, global.timingDbId, "Timer Tags").catch((err) => {
streamDeck.logger.error("Failed to fetch tag options:", err);
return [];
}),
]);
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, version: CURRENT_VERSION });
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, tags: tagsResult, version: CURRENT_VERSION });
} catch (err) {
streamDeck.logger.error("Failed to fetch projects:", err);
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION });
@@ -173,8 +180,9 @@ function isConfigured(g: GlobalSettings): boolean {
return !!(g.notionToken && g.userId);
}
function buttonTitle(projectName: string): string {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
function buttonTitle(projectName: string, tag?: string): string {
const base = projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
return tag ? `${base} \u00B7 ${tag}` : base;
}
const ELAPSED_CHECK_MS = 60 * 60 * 1000; // 1 hour
@@ -192,6 +200,25 @@ async function promptContinue(title: string): Promise<"stop" | "continue" | "tim
});
}
// Cancelling or letting the dialog time out means "don't start the timer" —
// the caller treats ok:false as a no-op, not as "start with no description".
async function promptDescription(title: string): Promise<{ ok: boolean; text: string }> {
const { execFile } = await import("node:child_process");
const safeTitle = title.replace(/[\\"]/g, "");
const script = `display dialog "Description for ${safeTitle} (optional):" default answer "" buttons {"Cancel", "Start"} default button "Start" giving up after ${DIALOG_TIMEOUT_SECONDS}`;
return new Promise((resolve) => {
execFile("osascript", ["-e", script], (err, stdout) => {
const out = String(stdout);
if (err || /gave up:true/.test(out) || !/button returned:Start/.test(out)) {
resolve({ ok: false, text: "" });
return;
}
const match = /text returned:(.*?)(?:, gave up:|$)/.exec(out);
resolve({ ok: true, text: match ? match[1].trim() : "" });
});
});
}
@action({ UUID: "com.pdma.notion-timer.toggle" })
class TimerToggle extends SingletonAction<TimerSettings> {
private projectCache = new Map<string, TimerSettings>();
@@ -238,7 +265,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || "");
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
await loadRunningState();
const isRunning = memRunningActionId === ev.action.id;
if (isRunning) {
@@ -262,7 +289,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
// Notion edit) — a pure visual correction, no Notion calls. Delayed slightly
// because redrawing immediately can collide with Stream Deck's own native
// key-press flash animation and get silently dropped.
const title = buttonTitle(ev.payload.settings.projectName || "");
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
const shouldBeRunning = memRunningActionId === ev.action.id;
await new Promise((resolve) => setTimeout(resolve, 150));
await Promise.all([
@@ -273,10 +300,20 @@ class TimerToggle extends SingletonAction<TimerSettings> {
}
this.keyDownBusy = true;
try {
const { projectId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || "");
const { projectId, projectName, tag, askDescription } = ev.payload.settings;
const title = buttonTitle(projectName || "", tag);
const isRunning = memRunningActionId === ev.action.id;
// Asking for a description blocks on a native dialog, so it has to happen
// before any optimistic UI update — a cancel/timeout must leave everything
// exactly as it was, not flip the button and then silently revert it.
let description = "";
if (!isRunning && projectId && askDescription) {
const result = await promptDescription(title);
if (!result.ok) return;
description = result.text;
}
// Instant visual feedback — no setSettings, no flash
if (projectId) {
if (isRunning) {
@@ -286,7 +323,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
if (other.id === ev.action.id) continue;
if (memRunningActionId === other.id) {
const s = this.projectCache.get(other.id);
await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || ""))]);
await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || "", s?.tag))]);
}
}
await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]);
@@ -321,7 +358,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
setRunningEntry(null, null);
this.clearElapsedCheck();
}
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId);
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId, tag, description);
setRunningEntry(entryId, ev.action.id);
this.scheduleElapsedCheck(entryId, ev.action.id, title);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]);
@@ -370,7 +407,7 @@ streamDeck.actions.registerAction(new LaunchNextcloud());
streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || "");
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
if (title) await ev.action.setTitle(title);
}
if (ev.payload.event === "refreshProjects") {