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:
+49
-12
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user