extract sync orchestration into sync.js, unify on sync.tick heartbeat

caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-15 19:02:44 +02:00
1 parent b7f450e41f
commit 1d6499e5dd
11 files changed
+400 -335

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.0');
define('APP_VERSION', '1.9.2');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.0",
"version": "1.9.2",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
+3 -3
View File
@@ -1834,9 +1834,9 @@ if (str_starts_with($route, '/api/')) {
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted flex-1" for="setting-sync-interval">Poll every</label>
<input id="setting-sync-interval" type="number" min="5" max="3600" step="1" class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none" style="width:72px;text-align:right;">
<span class="text-xs text-t-dim">sec</span>
<label class="text-xs text-t-muted" for="setting-sync-interval">Poll every</label>
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</span>
</div>
</div>
</div>
+2 -1
View File
@@ -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) ──
+8 -294
View File
@@ -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 =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}</div>`;
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();
+2 -1
View File
@@ -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');
+362
View File
@@ -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 =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}</div>`;
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);
};
}
}
+6
View File
@@ -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') {
+13 -32
View File
@@ -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
+1 -1
View File
@@ -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