diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js
index 4a0337d..8a26a0e 100644
--- a/public/js/modules/caldav.js
+++ b/public/js/modules/caldav.js
@@ -173,6 +173,7 @@ $('caldav-save')?.addEventListener('click', async () => {
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
export async function triggerSync() {
+ pulseAutosyncDot();
const btn = $('cal-sync');
if (btn) btn.style.opacity = '0.5';
const headerIcon = $('header-sync-icon');
@@ -285,6 +286,19 @@ export async function loadRelaySettings() {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
+ // Sync-interval number input (shared setting, persisted in localStorage).
+ // `change` commits on blur or Enter; `input` would save on every keystroke
+ // and restart the timer on each digit typed — noisy and pointless.
+ const intervalInp = $('setting-sync-interval');
+ if (intervalInp) {
+ intervalInp.value = String(getSyncIntervalSec());
+ intervalInp.onchange = () => {
+ setSyncIntervalSec(intervalInp.value);
+ // Reflect the clamped value back so users see when we refused a 2
+ // or 5000.
+ intervalInp.value = String(getSyncIntervalSec());
+ };
+ }
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
@@ -350,6 +364,7 @@ function drainSyncEvents(relayRes) {
async function backgroundSync() {
if (state.bgSyncRunning) return;
state.bgSyncRunning = true;
+ pulseAutosyncDot();
try {
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, relayRes] = await Promise.all([
@@ -369,13 +384,89 @@ async function backgroundSync() {
}
}
+// ── Configurable sync interval ────────────────────────────────
+// One setting drives both the CalDAV/relay background sync and the
+// collab-buckets plugin poll. Stored in localStorage as seconds.
+// Changed via the Relay settings panel → emits `sync.interval.changed`
+// so every poller can restart with the new cadence without reload.
+const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
+const SYNC_INTERVAL_DEFAULT = 15; // snappy default — tune up on slow machines or paid relays
+const SYNC_INTERVAL_MIN = 5;
+const SYNC_INTERVAL_MAX = 3600;
+export function getSyncIntervalSec() {
+ const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
+ return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
+}
+export function setSyncIntervalSec(sec) {
+ const n = parseInt(sec);
+ const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
+ localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
+ window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
+}
+
+// ── Autosync on/off toggle ────────────────────────────────────
+// User can pause background polling from the header (play/pause icon
+// next to the Sync button). Setting persists in localStorage.
+const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
+export function isAutoSyncEnabled() {
+ return localStorage.getItem(AUTOSYNC_KEY) !== '0';
+}
+export function setAutoSyncEnabled(on) {
+ localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
+ if (on) startBackgroundSync();
+ else stopBackgroundSync();
+ updateAutosyncButton();
+ // Broadcast so collab-buckets (and any future pollers) pause alongside.
+ window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
+}
+
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
+ if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
backgroundSync();
- bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
+ bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
+ updateAutosyncButton();
}
export function stopBackgroundSync() {
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
+ updateAutosyncButton();
}
+
+// Restart the background timer when the user changes the interval.
+window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
+ if (bgSyncInterval) {
+ clearInterval(bgSyncInterval);
+ bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
+ }
+});
+
+// ── Header autosync button ────────────────────────────────────
+// Accent dot when on, grey when off; a single burst-pulse fires at the
+// start of every actual sync so the user sees the poll happen.
+function updateAutosyncButton() {
+ const btn = $('header-autosync');
+ if (!btn) return;
+ const on = isAutoSyncEnabled();
+ btn.dataset.autosync = on ? 'on' : 'off';
+ btn.setAttribute('data-tip', on
+ ? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
+ : 'auto-sync: paused — click to resume');
+}
+
+// Pulse the dot once; retriggers cleanly even if called mid-animation by
+// ripping the class and forcing a reflow before re-adding.
+export function pulseAutosyncDot() {
+ const dot = $('header-autosync-dot');
+ if (!dot) return;
+ dot.classList.remove('pulsing');
+ void dot.offsetWidth;
+ dot.classList.add('pulsing');
+}
+// Also accept pulses from other pollers (collab-buckets, etc.) via hooks.
+window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
+$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
+// Also refresh tooltip when interval changes
+window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
+updateAutosyncButton();
diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js
index 55a6b82..15953fa 100644
--- a/public/plugins/collab-buckets/collab-buckets.js
+++ b/public/plugins/collab-buckets/collab-buckets.js
@@ -27,7 +27,13 @@ let collabByUid = new Map();
// card_id (number) -> true for cards with foreign edits we haven't opened yet
let unreadCards = new Set();
let pollHandle = null;
-const POLL_INTERVAL_MS = 30000;
+// The core CalDAV/relay sync cadence (Settings → Relay → Sync frequency,
+// default 15s) also drives the collab poll. Users can dial up or down
+// without touching code. Defaults + clamp mirror the core helper.
+function getPollIntervalMs() {
+ const v = parseInt(localStorage.getItem('hyprcrd-sync-interval-sec'));
+ return (Number.isFinite(v) && v >= 5 ? v : 15) * 1000;
+}
// Phase 4: edit locks (now exclusive — server-enforced)
// card_uid -> { bucket_uid, holder_username, expires_at }
@@ -69,6 +75,7 @@ async function pollOnce() {
if (document.getElementById('modal')?.classList.contains('open')) return;
try {
pollLocked = true;
+ hyprcrd.hooks?.emit?.('sync.pulse');
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return;
if (res.self) window._collabSelf = res.self;
@@ -166,12 +173,23 @@ function showQueuedToast(message) {
function startPolling() {
stopPolling();
- pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
+ pollHandle = setInterval(pollOnce, getPollIntervalMs());
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
+// Restart when the user changes the sync interval in settings.
+hyprcrd.hooks?.on?.('sync.interval.changed', () => {
+ if (pollHandle) startPolling();
+});
+
+// Pause / resume alongside the header autosync toggle.
+hyprcrd.hooks?.on?.('autosync.changed', (on) => {
+ if (on) startPolling();
+ else stopPolling();
+});
+
// ── Bucket bar decoration ─────────────────────────────────────────
/**
@@ -974,7 +992,9 @@ async function init() {
decorateBucketBar();
decorateBoardLocks();
decorateBoardUnread();
- startPolling();
+ // Respect the header autosync toggle — if the user has paused it,
+ // skip the recurring poll entirely. Hook above restarts when toggled.
+ if (localStorage.getItem('hyprcrd-autosync-enabled') !== '0') startPolling();
}
// Defer init until the CSRF token is live — `checkAuth()` sets it async on