Compare commits

...

3 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
6 changed files with 85 additions and 162 deletions

View File

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

View File

@@ -1,2 +1 @@
Yx<■bМwHW.Eш?сI* ║╘%g╘жЮтр>eШаk╡╒Н┤*иГ╨o
hг@÷╘╚╞=л
в<╥!Ъ░xR√з└С;=▀LI" я╓╢HuэF├ы▐Ос─╒┘ sb▌Y╛{йH│╤Н.JщNЦь1╔l

View File

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

Binary file not shown.

View File

@@ -1,4 +1,4 @@
const CURRENT_VERSION = "1.0.36";
const CURRENT_VERSION = "1.0.39";
const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
@@ -52,7 +52,6 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
const path = await import("path");
const pluginRoot = path.join(path.dirname(__filename), "..");
// Update UI and image assets
const ASSETS = [
"ui/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"];
for (const f of LEGACY) {
try { fs.unlinkSync(path.join(pluginRoot, f)); } catch { /* already gone */ }
@@ -98,12 +96,12 @@ interface GlobalSettings {
projectsDbId: string;
userId: string;
runningEntryId?: string | null;
runningActionId?: string | null;
}
interface TimerSettings {
projectId: string;
projectName: string;
activeEntryId: string | null;
}
const HARDCODED = {
@@ -116,21 +114,24 @@ async function getGlobal(): Promise<GlobalSettings> {
return { ...stored, ...HARDCODED };
}
// In-memory cache so onWillAppear can check running state without an async round-trip
let memRunningEntryId: string | null | undefined = undefined; // undefined = not yet loaded
// In-memory running state — avoids async round-trips on every button press
let memRunningEntryId: string | null | undefined = undefined;
let memRunningActionId: string | null | undefined = undefined;
async function getRunningEntryId(): Promise<string | null> {
if (memRunningEntryId === undefined) {
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
memRunningEntryId = stored.runningEntryId ?? null;
}
return memRunningEntryId;
async function loadRunningState(): Promise<void> {
if (memRunningEntryId !== undefined) return;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
memRunningEntryId = stored.runningEntryId ?? null;
memRunningActionId = stored.runningActionId ?? null;
}
async function setRunningEntry(entryId: string | null): Promise<void> {
memRunningEntryId = entryId;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
await streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId });
function setRunningEntry(entryId: string | null, actionId: string | null): void {
memRunningEntryId = entryId;
memRunningActionId = actionId;
// 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> {
@@ -159,36 +160,23 @@ function isConfigured(g: GlobalSettings): boolean {
return !!(g.notionToken && g.userId);
}
// Strip leading emoji characters so the button title shows just the project name
function buttonTitle(projectName: string): string {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
}
@action({ UUID: "com.pdma.notion-timer.toggle" })
class TimerToggle extends SingletonAction<TimerSettings> {
private settingsCache = new Map<string, TimerSettings>();
private projectCache = new Map<string, TimerSettings>();
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
const { activeEntryId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || "");
// Use in-memory cache to determine correct state before rendering — no flash
const running = await getRunningEntryId();
const isRunning = !!activeEntryId && activeEntryId === running;
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)]);
this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || "");
await loadRunningState();
const isRunning = memRunningActionId === ev.action.id;
if (isRunning) {
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
} else {
this.settingsCache.set(ev.action.id, ev.payload.settings);
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)]);
}
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
}
@@ -197,21 +185,20 @@ class TimerToggle extends SingletonAction<TimerSettings> {
}
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> {
this.settingsCache.set(ev.action.id, ev.payload.settings);
const { projectId, projectName, activeEntryId } = ev.payload.settings;
const { projectId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || "");
const isRunning = memRunningActionId === ev.action.id;
// Instant visual feedback before any async work
// Instant visual feedback — no setSettings, no flash
if (projectId) {
if (activeEntryId) {
if (isRunning) {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
// Turn off any other running button immediately using the cache — no async needed
for (const other of this.actions) {
if (other.id === ev.action.id) continue;
const otherSettings = this.settingsCache.get(other.id);
if (otherSettings?.activeEntryId) {
await Promise.all([other.setState(0), other.setTitle(buttonTitle(otherSettings.projectName || ""))]);
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(`${title}`)]);
@@ -219,61 +206,28 @@ class TimerToggle extends SingletonAction<TimerSettings> {
}
const global = await getGlobal();
if (!isConfigured(global)) {
await Promise.all([ev.action.setState(activeEntryId ? 1 : 0), ev.action.setTitle(activeEntryId ? `${title}` : title)]);
await ev.action.showAlert();
return;
}
if (!projectId) {
await ev.action.showAlert();
return;
}
if (!isConfigured(global)) { await ev.action.showAlert(); return; }
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);
// Re-assert immediately — before setRunningEntry's async calls
if (isRunning) {
await stopTimer(global.notionToken, memRunningEntryId!);
setRunningEntry(null, null);
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
await setRunningEntry(null);
} else {
const prevEntryId = await getRunningEntryId();
// Stop previous timer and update its settings
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);
}
}
if (memRunningEntryId) {
await stopTimer(global.notionToken, memRunningEntryId);
}
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);
// Re-assert immediately — before setRunningEntry's async calls
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(`${title}`)]);
await setRunningEntry(entryId);
}
} catch (err) {
// Revert optimistic visual on error
await Promise.all([ev.action.setState(activeEntryId ? 1 : 0), ev.action.setTitle(activeEntryId ? `${title}` : title)]);
// 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);
await ev.action.showAlert();
}
@@ -283,7 +237,6 @@ class TimerToggle extends SingletonAction<TimerSettings> {
const timerAction = new TimerToggle();
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) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings);
@@ -302,5 +255,4 @@ streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?:
streamDeck.connect();
// Check for updates 10 seconds after startup to avoid disrupting initial connection
setTimeout(() => checkForUpdates(), 10_000);

View File

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