";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
index 10fa70e..9dac597 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1834,9 +1834,9 @@ if (str_starts_with($route, '/api/')) {
sync frequency
How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.
- Poll every
-
- sec
+ Poll every
+
+ 15s
diff --git a/public/js/app.js b/public/js/app.js
index 5fcac02..ac9a02d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -18,7 +18,8 @@ import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcut
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
-import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
+import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
+import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
import { openPluginSettings } from './modules/plugin-settings.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js
index 8a26a0e..d2639dc 100644
--- a/public/js/modules/caldav.js
+++ b/public/js/modules/caldav.js
@@ -1,7 +1,13 @@
-// ── CalDAV Sync ──
+// ── CalDAV Settings + Calendar Config ──
+//
+// Genuinely CalDAV-scoped concerns only:
+// * Settings panel (connection URL / user / password, Test + Save)
+// * Per-calendar color/icon configuration (persisted in localStorage)
+//
+// Sync orchestration (triggerSync, background poll, autosync toggle,
+// sync-interval, relay settings, sync.tick hook) lives in sync.js.
import { $, esc, api, getCsrf } from './util.js';
-import { state } from './state.js';
const CAL_ICONS = [
{ id: 'calendar-dots', label: 'calendar' },
@@ -119,7 +125,6 @@ export async function loadSettings() {
}
}
-
function setCaldavTestStatus(text, kind = 'info') {
if (!testStatus) return;
if (!text) {
@@ -170,54 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
testStatus.style.color = 'var(--t-accent)';
});
-$('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');
- if (headerIcon) headerIcon.classList.add('card-spinner');
- if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
-
- const res = await fetch('api/caldav/sync', {
- method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
- }).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
-
- if (btn) btn.style.opacity = '';
-
- // Also poll relay
- // (header spinner stays on until the relay poll completes too — see end)
- const relayRes = await fetch('api/relay/poll', {
- method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
- }).then(r => r.ok ? r.json() : null).catch(() => null);
- drainSyncEvents(relayRes);
-
- if (res.ok) {
- const s = res.stats || {};
- const rp = relayRes?.pulled || 0;
- const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
- const allLog = res.log || [];
- const hasErrors = allLog.some(l => l.status === 'error');
- const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
- if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
- await window._reloadAll?.();
- showLog(allLog);
- showSyncNotification(total);
- } else {
- if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
- }
- if (headerIcon) headerIcon.classList.remove('card-spinner');
-}
-
-function showLog(log) {
- if (!logEl || !log?.length) return;
- logEl.classList.remove('hidden');
- logEl.innerHTML = log.map(l =>
- `${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}
`
- ).join('');
-}
-
function showConnectionLog(lines) {
if (!logEl || !lines?.length) return;
logEl.classList.remove('hidden');
@@ -226,247 +183,4 @@ function showConnectionLog(lines) {
).join('');
}
-// ── Relay settings ──
-
-function setRelayStatus(text, kind = 'info') {
- const el = $('relay-status');
- if (!el) return;
- if (!text) {
- el.classList.add('hidden');
- el.textContent = '';
- el.style.borderColor = '';
- el.style.color = '';
- return;
- }
- el.classList.remove('hidden');
- el.textContent = text;
- if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
- else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
- else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
-}
-
-$('relay-save')?.addEventListener('click', async () => {
- const body = {
- url: $('relay-url')?.value || '',
- username: $('relay-username')?.value || '',
- key: $('relay-key')?.value || '',
- };
- setRelayStatus('saving...');
- await api('api/relay/settings', { method: 'POST', body });
- setRelayStatus('saved', 'success');
- setTimeout(() => setRelayStatus(''), 1500);
-});
-
-$('relay-test')?.addEventListener('click', async () => {
- setRelayStatus('testing...');
- const res = await fetch('api/relay/poll', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
- body: JSON.stringify({ strict: true }),
- }).then(r => r.json()).catch(() => null);
- if (res?.ok) {
- setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
- if (res.pulled) await window._reloadAll?.();
- drainSyncEvents(res);
- } else {
- const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
- setRelayStatus((res?.error || 'failed') + detail, 'error');
- }
-});
-
-export async function loadRelaySettings() {
- const s = await api('api/relay/settings');
- if (!s) return;
- const urlInput = $('relay-url');
- const userInput = $('relay-username');
- const keyInput = $('relay-key');
- if (urlInput) urlInput.value = s.url || '';
- if (userInput) userInput.value = s.username || '';
- if (keyInput) {
- 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);
-$('cal-sync')?.addEventListener('click', () => triggerSync());
-$('header-sync')?.addEventListener('click', () => triggerSync());
-
-// Auto-sync throttle
-let lastAutoSync = 0;
-export function autoSync() {
- if (window._testRunning) return;
- const now = Date.now();
- if (now - lastAutoSync < 60000) return;
- lastAutoSync = now;
- triggerSync();
-}
-
-// Background sync every 2 minutes
-function showSyncNotification(stats) {
- const s = stats || {};
- const parts = [];
- if (s.pulled) parts.push(`${s.pulled} received`);
- if (s.pushed) parts.push(`${s.pushed} sent`);
- if (s.deleted) parts.push(`${s.deleted} deleted`);
- if (!parts.length) return;
-
- const note = document.createElement('div');
- note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
- note.innerHTML = ` CalDAV: ${parts.join(', ')}
`;
- document.body.appendChild(note);
- setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
-}
-
-// One toast per incoming sync event: "alice synced: Card Title".
-// Stacks downward so a burst of multiple doesn't overlay each other.
-let syncEventToastOffset = 0;
-function showSyncEventToast(ev) {
- const body = String(ev?.body || '').trim();
- if (!body) return;
- const bottom = 110 + syncEventToastOffset * 48;
- syncEventToastOffset++;
- const note = document.createElement('div');
- note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
- note.innerHTML = ` ${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
`;
- document.body.appendChild(note);
- setTimeout(() => {
- note.style.transition = 'opacity 0.3s';
- note.style.opacity = '0';
- setTimeout(() => {
- note.remove();
- syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
- }, 350);
- }, 5000);
-}
-
-function drainSyncEvents(relayRes) {
- if (relayRes?.sync_debug) {
- console.debug('[sync-events]', relayRes.sync_debug);
- }
- const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
- for (const ev of events) showSyncEventToast(ev);
-}
-
-async function backgroundSync() {
- if (state.bgSyncRunning) return;
- state.bgSyncRunning = true;
- pulseAutosyncDot();
- try {
- const headers = { 'X-CSRF-Token': getCsrf() };
- const [res, relayRes] = await Promise.all([
- fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
- fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
- ]);
- const s = res?.stats || {};
- const rp = relayRes?.pulled || 0;
- const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
- if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
- await window._reloadAll?.();
- showSyncNotification(total);
- }
- drainSyncEvents(relayRes);
- } catch {} finally {
- state.bgSyncRunning = false;
- }
-}
-
-// ── 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, 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/js/modules/calendar.js b/public/js/modules/calendar.js
index 12c07a9..16bf129 100644
--- a/public/js/modules/calendar.js
+++ b/public/js/modules/calendar.js
@@ -3,7 +3,8 @@
import { $, esc } from './util.js';
import { state } from './state.js';
import { stripMd, getTodos } from './markdown.js';
-import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
+import { autoSync } from './sync.js';
+import { getCalConfig, getAllCalConfigs } from './caldav.js';
import { renderCardHtml } from './board.js';
const overlay = $('calendar');
diff --git a/public/js/modules/sync.js b/public/js/modules/sync.js
new file mode 100644
index 0000000..1b56057
--- /dev/null
+++ b/public/js/modules/sync.js
@@ -0,0 +1,362 @@
+// ── Sync Orchestration ─────────────────────────────────────────────
+//
+// Owns the single source of truth for "time to sync". Every scheduled
+// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
+// the chat plugin — hangs off the same interval via the `sync.tick` hook
+// instead of running its own setInterval. That way:
+//
+// - The header Sync button triggers ALL syncs (emits `sync.tick`).
+// - The Autosync toggle pauses ALL syncs (stops the single timer).
+// - The configurable interval changes ALL cadences at once.
+//
+// This module also owns the autosync button UI, the sync-event toast
+// feed, and the relay settings panel (which hosts the interval input).
+//
+// Hooks emitted:
+// sync.tick — fire all pollers now
+// sync.pulse — pulse the header dot (subset of tick)
+// sync.interval.changed — restart timers with new cadence
+// autosync.changed — user paused/resumed scheduled polling
+//
+// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
+// in caldav.js. This file has no CalDAV icons/colors concerns.
+
+import { $, api, getCsrf } from './util.js';
+import { state } from './state.js';
+
+const syncStatus = $('caldav-sync-status');
+
+// ── Manual "Sync now" trigger ───────────────────────────────────
+// Fired by the header Sync button, the CalDAV panel "sync now" button,
+// and the `autoSync()` throttled helper. Always runs regardless of the
+// autosync-paused setting — the user pressed a button, we honor that.
+// Fans out to every plugin poller via `sync.tick`.
+export async function triggerSync() {
+ pulseAutosyncDot();
+ const btn = $('cal-sync');
+ if (btn) btn.style.opacity = '0.5';
+ const headerIcon = $('header-sync-icon');
+ if (headerIcon) headerIcon.classList.add('card-spinner');
+ if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
+
+ const res = await fetch('api/caldav/sync', {
+ method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
+ }).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
+
+ if (btn) btn.style.opacity = '';
+
+ // Also poll relay for mail-style messages. Header spinner stays on
+ // until this completes too.
+ const relayRes = await fetch('api/relay/poll', {
+ method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
+ }).then(r => r.ok ? r.json() : null).catch(() => null);
+ drainSyncEvents(relayRes);
+
+ // Fan out to plugin pollers (collab-buckets, chat, …). They run
+ // concurrently with us — no await needed here.
+ window.hyprcrd?.hooks?.emit?.('sync.tick');
+
+ if (res.ok) {
+ const s = res.stats || {};
+ const rp = relayRes?.pulled || 0;
+ const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
+ const allLog = res.log || [];
+ const hasErrors = allLog.some(l => l.status === 'error');
+ const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
+ if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
+ await window._reloadAll?.();
+ showLog(allLog);
+ showSyncNotification(total);
+ } else {
+ if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
+ }
+ if (headerIcon) headerIcon.classList.remove('card-spinner');
+}
+
+// CalDAV log display inside the Settings → CalDAV panel. Lives here
+// (not in caldav.js) because triggerSync writes to it.
+function showLog(log) {
+ const logEl = $('caldav-log');
+ if (!logEl || !log?.length) return;
+ logEl.classList.remove('hidden');
+ logEl.innerHTML = log.map(l =>
+ `${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}
`
+ ).join('');
+}
+
+// ── Auto-sync throttle ──────────────────────────────────────────
+// Used by the CalDAV module when the user saves a card with #termin or
+// #geburtstag — we want to fire a sync immediately but not storm the
+// server if they paste a big batch. 60s cooldown between auto-fires.
+let lastAutoSync = 0;
+export function autoSync() {
+ if (window._testRunning) return;
+ const now = Date.now();
+ if (now - lastAutoSync < 60000) return;
+ lastAutoSync = now;
+ triggerSync();
+}
+
+// ── Sync notifications ──────────────────────────────────────────
+
+function showSyncNotification(stats) {
+ const s = stats || {};
+ const parts = [];
+ if (s.pulled) parts.push(`${s.pulled} received`);
+ if (s.pushed) parts.push(`${s.pushed} sent`);
+ if (s.deleted) parts.push(`${s.deleted} deleted`);
+ if (!parts.length) return;
+
+ const note = document.createElement('div');
+ note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
+ note.innerHTML = ` CalDAV: ${parts.join(', ')}
`;
+ document.body.appendChild(note);
+ setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
+}
+
+// One toast per incoming sync event: "alice synced: Card Title".
+// Stacks downward so a burst of multiple doesn't overlay each other.
+let syncEventToastOffset = 0;
+function showSyncEventToast(ev) {
+ const body = String(ev?.body || '').trim();
+ if (!body) return;
+ const bottom = 110 + syncEventToastOffset * 48;
+ syncEventToastOffset++;
+ const note = document.createElement('div');
+ note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
+ note.innerHTML = ` ${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
`;
+ document.body.appendChild(note);
+ setTimeout(() => {
+ note.style.transition = 'opacity 0.3s';
+ note.style.opacity = '0';
+ setTimeout(() => {
+ note.remove();
+ syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
+ }, 350);
+ }, 5000);
+}
+
+function drainSyncEvents(relayRes) {
+ if (relayRes?.sync_debug) {
+ console.debug('[sync-events]', relayRes.sync_debug);
+ }
+ const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
+ for (const ev of events) showSyncEventToast(ev);
+}
+
+// ── Scheduled background pull ───────────────────────────────────
+// Internal helper — the CalDAV + relay mail side of a tick. Plugin
+// pollers run concurrently via the `sync.tick` hook.
+async function backgroundPull() {
+ if (state.bgSyncRunning) return;
+ state.bgSyncRunning = true;
+ try {
+ const headers = { 'X-CSRF-Token': getCsrf() };
+ const [res, relayRes] = await Promise.all([
+ fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
+ fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
+ ]);
+ const s = res?.stats || {};
+ const rp = relayRes?.pulled || 0;
+ const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
+ if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
+ await window._reloadAll?.();
+ showSyncNotification(total);
+ }
+ drainSyncEvents(relayRes);
+ } catch {} finally {
+ state.bgSyncRunning = false;
+ }
+}
+
+// Central tick: the one and only scheduled sync heartbeat. Fires the
+// CalDAV+relay pull and emits `sync.tick` for every other poller.
+function tick() {
+ pulseAutosyncDot();
+ backgroundPull();
+ window.hyprcrd?.hooks?.emit?.('sync.tick');
+}
+
+// ── Configurable sync interval ────────────────────────────────
+// One setting drives every scheduled poller. Changed via the Relay
+// settings panel; emits `sync.interval.changed` so the central timer
+// restarts on the new cadence without a page reload.
+const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
+const SYNC_INTERVAL_DEFAULT = 15;
+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 ────────────────────────────────────
+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();
+ window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
+}
+
+// ── Central tick timer ────────────────────────────────────────
+let tickInterval = null;
+export function startBackgroundSync() {
+ if (tickInterval) return;
+ if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
+ tick();
+ tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
+ updateAutosyncButton();
+}
+export function stopBackgroundSync() {
+ if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
+ updateAutosyncButton();
+}
+
+// Restart the central timer when the user changes the interval.
+window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
+ if (tickInterval) {
+ clearInterval(tickInterval);
+ tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
+ }
+});
+
+// ── Header autosync button ────────────────────────────────────
+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. Rip and re-add the class after a reflow so
+// re-triggering mid-animation restarts cleanly.
+export function pulseAutosyncDot() {
+ const dot = $('header-autosync-dot');
+ if (!dot) return;
+ dot.classList.remove('pulsing');
+ void dot.offsetWidth;
+ dot.classList.add('pulsing');
+}
+window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
+$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
+window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
+updateAutosyncButton();
+
+// ── Sync-adjacent button wiring ───────────────────────────────
+$('cal-sync')?.addEventListener('click', () => triggerSync());
+$('header-sync')?.addEventListener('click', () => triggerSync());
+$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
+
+// ── Relay settings panel ──────────────────────────────────────
+// The relay panel lives in Settings → Relay. It holds the URL /
+// username / shared-key inputs plus the shared sync-interval number
+// input that drives every scheduled poller.
+
+function setRelayStatus(text, kind = 'info') {
+ const el = $('relay-status');
+ if (!el) return;
+ if (!text) {
+ el.classList.add('hidden');
+ el.textContent = '';
+ el.style.borderColor = '';
+ el.style.color = '';
+ return;
+ }
+ el.classList.remove('hidden');
+ el.textContent = text;
+ if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
+ else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
+ else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
+}
+
+$('relay-save')?.addEventListener('click', async () => {
+ const body = {
+ url: $('relay-url')?.value || '',
+ username: $('relay-username')?.value || '',
+ key: $('relay-key')?.value || '',
+ };
+ setRelayStatus('saving...');
+ await api('api/relay/settings', { method: 'POST', body });
+ setRelayStatus('saved', 'success');
+ setTimeout(() => setRelayStatus(''), 1500);
+});
+
+$('relay-test')?.addEventListener('click', async () => {
+ setRelayStatus('testing...');
+ const res = await fetch('api/relay/poll', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
+ body: JSON.stringify({ strict: true }),
+ }).then(r => r.json()).catch(() => null);
+ if (res?.ok) {
+ setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
+ if (res.pulled) await window._reloadAll?.();
+ drainSyncEvents(res);
+ } else {
+ const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
+ setRelayStatus((res?.error || 'failed') + detail, 'error');
+ }
+});
+
+export async function loadRelaySettings() {
+ const s = await api('api/relay/settings');
+ if (!s) return;
+ const urlInput = $('relay-url');
+ const userInput = $('relay-username');
+ const keyInput = $('relay-key');
+ if (urlInput) urlInput.value = s.url || '';
+ if (userInput) userInput.value = s.username || '';
+ if (keyInput) {
+ keyInput.value = s.key || '';
+ keyInput.placeholder = 'shared secret';
+ }
+ // Sync-interval slider lives in the Relay panel but drives every
+ // poller. Snap points span 5s..1h; live label updates on drag,
+ // persisted value commits on release.
+ const intervalInp = $('setting-sync-interval');
+ const intervalVal = $('setting-sync-interval-val');
+ if (intervalInp) {
+ const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
+ const fmt = (s) => {
+ if (s < 60) return `${s}s`;
+ if (s === 3600) return '1h';
+ const m = Math.floor(s / 60), r = s % 60;
+ return r ? `${m}m ${r}s` : `${m}m`;
+ };
+ const idxFor = (sec) => {
+ let best = 0, bestDiff = Infinity;
+ for (let i = 0; i < STEPS.length; i++) {
+ const d = Math.abs(STEPS[i] - sec);
+ if (d < bestDiff) { bestDiff = d; best = i; }
+ }
+ return best;
+ };
+ intervalInp.max = String(STEPS.length - 1);
+ const sync = (idx) => {
+ const sec = STEPS[idx];
+ if (intervalVal) intervalVal.textContent = fmt(sec);
+ return sec;
+ };
+ intervalInp.value = String(idxFor(getSyncIntervalSec()));
+ sync(parseInt(intervalInp.value));
+ intervalInp.oninput = () => sync(parseInt(intervalInp.value));
+ intervalInp.onchange = () => {
+ const sec = sync(parseInt(intervalInp.value));
+ setSyncIntervalSec(sec);
+ };
+ }
+}
diff --git a/public/plugins/chat/chat.js b/public/plugins/chat/chat.js
index c21a7f5..3772a48 100644
--- a/public/plugins/chat/chat.js
+++ b/public/plugins/chat/chat.js
@@ -410,6 +410,12 @@ function init() {
restartPoll();
}
});
+
+ // Also refresh on the global sync heartbeat. Our own timer keeps
+ // the faster flyout-open cadence (UX-specific); tick guarantees
+ // that the header Sync button refreshes chat too, even when the
+ // flyout is closed and our own timer sits at 20s.
+ hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
}
if (document.readyState === 'loading') {
diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js
index 15953fa..ad28259 100644
--- a/public/plugins/collab-buckets/collab-buckets.js
+++ b/public/plugins/collab-buckets/collab-buckets.js
@@ -4,9 +4,9 @@
// rows in the standard bucket bar. The plugin just owns the sync + the
// visual unread-state decoration:
//
-// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
-// mirrors each remote bucket into the main `buckets` table so the core
-// bucket bar renders it automatically.
+// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
+// in sync.js). The backend mirrors each remote bucket into the main
+// `buckets` table so the core bucket bar renders it automatically.
// - Exposes `window._collabBucketsCreateDialog` — an async function that
// the core + Bucket button calls so we can pop a richer "new bucket"
// modal with an optional "collaborative" checkbox + member picker.
@@ -26,14 +26,9 @@ const $ = (id) => document.getElementById(id);
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;
-// 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;
-}
+// Collab card sync is driven by the shared `sync.tick` hook from
+// sync.js — one timer fans out to every plugin poller, the central
+// timer honors the Autosync pause for us.
// Phase 4: edit locks (now exclusive — server-enforced)
// card_uid -> { bucket_uid, holder_username, expires_at }
@@ -171,24 +166,11 @@ function showQueuedToast(message) {
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
-function startPolling() {
- stopPolling();
- 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();
-});
+// Subscribe to the global sync heartbeat. The central timer in sync.js
+// honors the Autosync pause + the configurable interval for us, and
+// the header Sync button emits the same tick so manual sync pushes
+// collab cards too.
+hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
// ── Bucket bar decoration ─────────────────────────────────────────
@@ -992,9 +974,8 @@ async function init() {
decorateBucketBar();
decorateBoardLocks();
decorateBoardUnread();
- // 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();
+ // No local timer: subsequent polls come from the global `sync.tick`
+ // hook (central timer in sync.js honors Autosync pause + interval).
}
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js
index bf2f021..3d8139e 100644
--- a/public/plugins/tests/runner.js
+++ b/public/plugins/tests/runner.js
@@ -5,7 +5,7 @@
// Backup is created before and auto-restored after every run.
import { getCsrf } from '../../js/modules/util.js';
-import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
+import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/sync.js';
// Suites live on window so two runner.js instances (one cached and loaded
// by test files via bare imports, one fresh and loaded by loader.js with