Compare commits

...

8 Commits

Author SHA1 Message Date
pdmarf
060a2bc917 v1.0.39: fix button flash by making setRunningEntry synchronous
Remove await from running-state updates so onWillAppear cannot
fire between the optimistic setState(1) and the API call completing,
eliminating the green→blue→green flash on button press.
2026-04-24 09:29:43 +01:00
pdmarf
4171b2f6e9 v1.0.38: eliminate setSettings from onKeyDown to fix flash
setSettings always resets the button visual state — no workaround
exists. Removed activeEntryId from per-button settings entirely.
Running state now tracked via runningActionId in global settings
(alongside runningEntryId). onWillAppear restores state from
memRunningActionId. onKeyDown only calls setState — clean, instant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:20:41 +01:00
pdmarf
35a0bbb867 v1.0.37: send setSettings and setState simultaneously to avoid flash
Previously setSettings was awaited first (causing blue flash), then
setState was called. Now both are sent in the same Promise.all so
Stream Deck processes setState in the same batch, overriding the
visual reset from setSettings with no visible blue transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:15:48 +01:00
pdmarf
5cdc77ccf3 v1.0.36: eliminate blue flash between setSettings and setState
setState was being called after setRunningEntry, which makes two
async global settings calls and takes ~1 second. During that time
the button sat in the blue reset state from setSettings. Now
setState is called immediately after setSettings, before
setRunningEntry, so the blue flash is imperceptibly brief.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:13:38 +01:00
pdmarf
bb021633a3 v1.0.35: re-assert setState(0) after setSettings on stop path
setSettings() resets the visual state in both directions. The start
path already re-asserted setState(1) after setSettings. The stop path
was missing the equivalent setState(0), leaving the button green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:11:30 +01:00
pdmarf
93616d3a1a v1.0.34: fix both buttons showing green simultaneously
When pressing B while A was running, B went green immediately but A
stayed green until getGlobal()+getRunningEntryId() resolved. Now uses
settingsCache to turn off all running buttons in the same synchronous
pass as turning B on — no async gap where both appear active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:07:55 +01:00
pdmarf
2fd2b6ad8a v1.0.33: instant button feedback on key press
Visual state change now happens before any async work (getGlobal,
getRunningEntryId, API calls). Previously the button waited for
getGlobal() to resolve before going green, causing a 1-2s delay.
Also reverts optimistic state on API error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:05:41 +01:00
pdmarf
e3a19234a9 v1.0.32: re-assert button state after setSettings resets it
setSettings() causes Stream Deck to reset the button visual back to
its default state (state 0). The optimistic setState(1) was correct
but got overridden. Now setState(1) is called again after setSettings
completes to ensure the button stays green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:01:55 +01:00
6 changed files with 109 additions and 168 deletions

View File

@@ -6438,7 +6438,7 @@ async function stopTimer(token, entryId) {
} }
// src/plugin.ts // src/plugin.ts
var CURRENT_VERSION = "1.0.31"; var CURRENT_VERSION = "1.0.39";
var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild"; var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
@@ -6528,17 +6528,17 @@ async function getGlobal() {
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
} }
var memRunningEntryId = void 0; var memRunningEntryId = void 0;
async function getRunningEntryId() { var memRunningActionId = void 0;
if (memRunningEntryId === void 0) { async function loadRunningState() {
const stored = await plugin_default.settings.getGlobalSettings(); if (memRunningEntryId !== void 0) return;
memRunningEntryId = stored.runningEntryId ?? null;
}
return memRunningEntryId;
}
async function setRunningEntry(entryId) {
memRunningEntryId = entryId;
const stored = await plugin_default.settings.getGlobalSettings(); const stored = await plugin_default.settings.getGlobalSettings();
await plugin_default.settings.setGlobalSettings({ ...stored, runningEntryId: entryId }); memRunningEntryId = stored.runningEntryId ?? null;
memRunningActionId = stored.runningActionId ?? null;
}
function setRunningEntry(entryId, actionId) {
memRunningEntryId = entryId;
memRunningActionId = actionId;
plugin_default.settings.getGlobalSettings().then((stored) => plugin_default.settings.setGlobalSettings({ ...stored, runningEntryId: entryId, runningActionId: actionId })).catch((err) => plugin_default.logger.error("Failed to persist running state:", err));
} }
async function sendProjectsToPI(tokenOverride) { async function sendProjectsToPI(tokenOverride) {
try { try {
@@ -6568,34 +6568,40 @@ function buttonTitle(projectName) {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
} }
var TimerToggle = class extends SingletonAction { var TimerToggle = class extends SingletonAction {
settingsCache = /* @__PURE__ */ new Map(); projectCache = /* @__PURE__ */ new Map();
async onWillAppear(ev) { async onWillAppear(ev) {
const { activeEntryId, projectName } = ev.payload.settings; this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "");
const running = await getRunningEntryId(); await loadRunningState();
const isRunning = !!activeEntryId && activeEntryId === running; const isRunning = memRunningActionId === ev.action.id;
if (activeEntryId && !isRunning) { if (isRunning) {
const cleared = { ...ev.payload.settings, activeEntryId: null }; await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
await ev.action.setSettings(cleared);
this.settingsCache.set(ev.action.id, cleared);
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else { } else {
this.settingsCache.set(ev.action.id, ev.payload.settings); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
if (isRunning) {
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
} else {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
} }
} }
async onPropertyInspectorDidAppear(_ev) { async onPropertyInspectorDidAppear(_ev) {
await sendProjectsToPI(); await sendProjectsToPI();
} }
async onKeyDown(ev) { async onKeyDown(ev) {
this.settingsCache.set(ev.action.id, ev.payload.settings); const { projectId, projectName } = ev.payload.settings;
const global = await getGlobal();
const { projectId, projectName, activeEntryId } = ev.payload.settings;
const title = buttonTitle(projectName || ""); const title = buttonTitle(projectName || "");
const isRunning = memRunningActionId === ev.action.id;
if (projectId) {
if (isRunning) {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
for (const other of this.actions) {
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([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
}
const global = await getGlobal();
if (!isConfigured(global)) { if (!isConfigured(global)) {
await ev.action.showAlert(); await ev.action.showAlert();
return; return;
@@ -6605,51 +6611,23 @@ var TimerToggle = class extends SingletonAction {
return; return;
} }
try { try {
if (activeEntryId) { if (isRunning) {
await stopTimer(global.notionToken, activeEntryId); await stopTimer(global.notionToken, memRunningEntryId);
const stopped = { ...ev.payload.settings, activeEntryId: null }; setRunningEntry(null, null);
await ev.action.setSettings(stopped); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
this.settingsCache.set(ev.action.id, stopped);
await ev.action.setState(0);
await ev.action.setTitle(title);
await setRunningEntry(null);
} else { } else {
const prevEntryId = await getRunningEntryId(); if (memRunningEntryId) {
if (prevEntryId) { await stopTimer(global.notionToken, memRunningEntryId);
for (const other of this.actions) {
if (other.id === ev.action.id) continue;
const otherSettings = this.settingsCache.get(other.id);
if (otherSettings?.activeEntryId === prevEntryId) {
await Promise.all([other.setState(0), other.setTitle(buttonTitle(otherSettings.projectName || ""))]);
}
}
} }
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId);
setRunningEntry(entryId, ev.action.id);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]); await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
if (prevEntryId) {
await stopTimer(global.notionToken, prevEntryId);
for (const other of this.actions) {
if (other.id === ev.action.id) continue;
const otherSettings = this.settingsCache.get(other.id);
if (otherSettings?.activeEntryId === prevEntryId) {
const stopped = { ...otherSettings, activeEntryId: null };
await other.setSettings(stopped);
this.settingsCache.set(other.id, stopped);
}
}
}
const entryId = await startTimer(
global.notionToken,
global.timingDbId,
projectId,
projectName,
global.userId
);
const started = { ...ev.payload.settings, activeEntryId: entryId };
await ev.action.setSettings(started);
this.settingsCache.set(ev.action.id, started);
await setRunningEntry(entryId);
} }
} catch (err) { } catch (err) {
await Promise.all([
ev.action.setState(isRunning ? 1 : 0),
ev.action.setTitle(isRunning ? `\u23F1 ${title}` : title)
]);
plugin_default.logger.error("Timer toggle failed:", err); plugin_default.logger.error("Timer toggle failed:", err);
await ev.action.showAlert(); await ev.action.showAlert();
} }

View File

@@ -1 +1 @@
XCá¹kf*j”˜vèk𮂿 в<╥!Ъ░xR√з└С;=▀LI" я╓╢HuэF├ы▐Ос─╒┘ sb▌Y╛{йH│╤Н.JщNЦь1╔l

View File

@@ -2,7 +2,7 @@
"Author": "Pete Marfleet", "Author": "Pete Marfleet",
"Description": "Toggle Notion time tracking for a project with a single button press.", "Description": "Toggle Notion time tracking for a project with a single button press.",
"Name": "Notion Timer", "Name": "Notion Timer",
"Version": "1.0.31", "Version": "1.0.39",
"SDKVersion": 2, "SDKVersion": 2,
"Software": { "MinimumVersion": "5.0" }, "Software": { "MinimumVersion": "5.0" },
"OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }], "OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }],

Binary file not shown.

View File

@@ -1,4 +1,4 @@
const CURRENT_VERSION = "1.0.31"; const CURRENT_VERSION = "1.0.39";
const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild"; const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
@@ -52,7 +52,6 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
const path = await import("path"); const path = await import("path");
const pluginRoot = path.join(path.dirname(__filename), ".."); const pluginRoot = path.join(path.dirname(__filename), "..");
// Update UI and image assets
const ASSETS = [ const ASSETS = [
"ui/property-inspector.html", "ui/property-inspector.html",
"ui/global-property-inspector.html", "ui/global-property-inspector.html",
@@ -67,7 +66,6 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
} }
} }
// Remove legacy SVG icons that persist on disk after Stream Deck merge-installs
const LEGACY = ["imgs/idle.svg", "imgs/running.svg"]; const LEGACY = ["imgs/idle.svg", "imgs/running.svg"];
for (const f of LEGACY) { for (const f of LEGACY) {
try { fs.unlinkSync(path.join(pluginRoot, f)); } catch { /* already gone */ } try { fs.unlinkSync(path.join(pluginRoot, f)); } catch { /* already gone */ }
@@ -98,12 +96,12 @@ interface GlobalSettings {
projectsDbId: string; projectsDbId: string;
userId: string; userId: string;
runningEntryId?: string | null; runningEntryId?: string | null;
runningActionId?: string | null;
} }
interface TimerSettings { interface TimerSettings {
projectId: string; projectId: string;
projectName: string; projectName: string;
activeEntryId: string | null;
} }
const HARDCODED = { const HARDCODED = {
@@ -116,21 +114,24 @@ async function getGlobal(): Promise<GlobalSettings> {
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
} }
// In-memory cache so onWillAppear can check running state without an async round-trip // In-memory running state — avoids async round-trips on every button press
let memRunningEntryId: string | null | undefined = undefined; // undefined = not yet loaded let memRunningEntryId: string | null | undefined = undefined;
let memRunningActionId: string | null | undefined = undefined;
async function getRunningEntryId(): Promise<string | null> { async function loadRunningState(): Promise<void> {
if (memRunningEntryId === undefined) { if (memRunningEntryId !== undefined) return;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>(); const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
memRunningEntryId = stored.runningEntryId ?? null; memRunningEntryId = stored.runningEntryId ?? null;
} memRunningActionId = stored.runningActionId ?? null;
return memRunningEntryId;
} }
async function setRunningEntry(entryId: string | null): Promise<void> { function setRunningEntry(entryId: string | null, actionId: string | null): void {
memRunningEntryId = entryId; memRunningEntryId = entryId;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>(); memRunningActionId = actionId;
await streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId }); // Persist in background — do not await, so the visual is never blocked
streamDeck.settings.getGlobalSettings<GlobalSettings>()
.then(stored => streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId, runningActionId: actionId }))
.catch(err => streamDeck.logger.error("Failed to persist running state:", err));
} }
async function sendProjectsToPI(tokenOverride?: string): Promise<void> { async function sendProjectsToPI(tokenOverride?: string): Promise<void> {
@@ -159,36 +160,23 @@ function isConfigured(g: GlobalSettings): boolean {
return !!(g.notionToken && g.userId); return !!(g.notionToken && g.userId);
} }
// Strip leading emoji characters so the button title shows just the project name
function buttonTitle(projectName: string): string { function buttonTitle(projectName: string): string {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
} }
@action({ UUID: "com.pdma.notion-timer.toggle" }) @action({ UUID: "com.pdma.notion-timer.toggle" })
class TimerToggle extends SingletonAction<TimerSettings> { class TimerToggle extends SingletonAction<TimerSettings> {
private settingsCache = new Map<string, TimerSettings>(); private projectCache = new Map<string, TimerSettings>();
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> { async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
const { activeEntryId, projectName } = ev.payload.settings; this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "");
await loadRunningState();
// Use in-memory cache to determine correct state before rendering — no flash const isRunning = memRunningActionId === ev.action.id;
const running = await getRunningEntryId(); if (isRunning) {
const isRunning = !!activeEntryId && activeEntryId === running; await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
if (activeEntryId && !isRunning) {
// Self-heal: this button thinks it's running but it's not — clear it
const cleared = { ...ev.payload.settings, activeEntryId: null };
await ev.action.setSettings(cleared);
this.settingsCache.set(ev.action.id, cleared);
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else { } else {
this.settingsCache.set(ev.action.id, ev.payload.settings); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
if (isRunning) {
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
} else {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
} }
} }
@@ -197,72 +185,49 @@ class TimerToggle extends SingletonAction<TimerSettings> {
} }
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> { async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> {
this.settingsCache.set(ev.action.id, ev.payload.settings); const { projectId, projectName } = ev.payload.settings;
const global = await getGlobal();
const { projectId, projectName, activeEntryId } = ev.payload.settings;
const title = buttonTitle(projectName || ""); const title = buttonTitle(projectName || "");
const isRunning = memRunningActionId === ev.action.id;
if (!isConfigured(global)) { // Instant visual feedback — no setSettings, no flash
await ev.action.showAlert(); if (projectId) {
return; if (isRunning) {
} await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
if (!projectId) {
await ev.action.showAlert();
return;
}
try {
if (activeEntryId) {
await stopTimer(global.notionToken, activeEntryId);
const stopped = { ...ev.payload.settings, activeEntryId: null };
await ev.action.setSettings(stopped);
this.settingsCache.set(ev.action.id, stopped);
await ev.action.setState(0);
await ev.action.setTitle(title);
await setRunningEntry(null);
} else { } else {
const prevEntryId = await getRunningEntryId(); for (const other of this.actions) {
if (other.id === ev.action.id) continue;
// Optimistically update visuals immediately — no waiting for API if (memRunningActionId === other.id) {
if (prevEntryId) { const s = this.projectCache.get(other.id);
for (const other of this.actions) { await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || ""))]);
if (other.id === ev.action.id) continue;
const otherSettings = this.settingsCache.get(other.id);
if (otherSettings?.activeEntryId === prevEntryId) {
await Promise.all([other.setState(0), other.setTitle(buttonTitle(otherSettings.projectName || ""))]);
}
} }
} }
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]); await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
}
}
// Now do the API calls const global = await getGlobal();
if (prevEntryId) { if (!isConfigured(global)) { await ev.action.showAlert(); return; }
await stopTimer(global.notionToken, prevEntryId); if (!projectId) { await ev.action.showAlert(); return; }
for (const other of this.actions) {
if (other.id === ev.action.id) continue; try {
const otherSettings = this.settingsCache.get(other.id); if (isRunning) {
if (otherSettings?.activeEntryId === prevEntryId) { await stopTimer(global.notionToken, memRunningEntryId!);
const stopped = { ...otherSettings, activeEntryId: null }; setRunningEntry(null, null);
await other.setSettings(stopped); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
this.settingsCache.set(other.id, stopped); } else {
} if (memRunningEntryId) {
} await stopTimer(global.notionToken, memRunningEntryId);
} }
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId);
const entryId = await startTimer( setRunningEntry(entryId, ev.action.id);
global.notionToken, await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
global.timingDbId,
projectId,
projectName,
global.userId,
);
const started = { ...ev.payload.settings, activeEntryId: entryId };
await ev.action.setSettings(started);
this.settingsCache.set(ev.action.id, started);
await setRunningEntry(entryId);
} }
} catch (err) { } catch (err) {
// Revert visual on error
await Promise.all([
ev.action.setState(isRunning ? 1 : 0),
ev.action.setTitle(isRunning ? `${title}` : title),
]);
streamDeck.logger.error("Timer toggle failed:", err); streamDeck.logger.error("Timer toggle failed:", err);
await ev.action.showAlert(); await ev.action.showAlert();
} }
@@ -272,7 +237,6 @@ class TimerToggle extends SingletonAction<TimerSettings> {
const timerAction = new TimerToggle(); const timerAction = new TimerToggle();
streamDeck.actions.registerAction(timerAction); streamDeck.actions.registerAction(timerAction);
// v2 requires using streamDeck.ui.onSendToPlugin — the SingletonAction method does not fire
streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => { streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) { if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings); await ev.action.setSettings(ev.payload.settings);
@@ -291,5 +255,4 @@ streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?:
streamDeck.connect(); streamDeck.connect();
// Check for updates 10 seconds after startup to avoid disrupting initial connection
setTimeout(() => checkForUpdates(), 10_000); setTimeout(() => checkForUpdates(), 10_000);

View File

@@ -1 +1 @@
{ "version": "1.0.31" } { "version": "1.0.39" }