autosync toggle, configurable sync interval, 30-day session

- header Sync button now has a paired autosync toggle to its right —
  reads as one button with no inner seam (outer border only). Dot inside
  pulses once per actual sync (bursts on every backgroundSync /
  triggerSync / collab pollOnce via a new sync.pulse hook) so the user
  can see polls happen. Dot + data-autosync attribute persist the
  enabled state in localStorage; paused state stops both the
  CalDAV/relay background timer and the collab poll via a broadcast
  autosync.changed hook.
- hover polish: Sync regains a full accent border (the missing inner
  edge re-appears only on its hover); the toggle fills with solid
  accent as hover treatment so the pair never shows a half-outline.
- sync interval moved out of hardcode into a settings field under
  Settings → Relay: number input, 5s..3600s, default 15s (was 45s on
  caldav / 30s on collab). Changes broadcast via sync.interval.changed
  and restart both timers without reload.
- session cookie lifetime raised to 30 days with matching gc_maxlifetime
  and SameSite=Lax (was 0 / Strict — some users got kicked to login on
  every reload when gc cleaned up the /tmp/sess file or a cross-link
  return blocked the cookie).
- search button's kbd element was pushing the button ~3px taller than
  Sync; leading-none + tighter padding puts them on the same height.

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 08:57:24 +02:00
1 parent 6cd00f9660
commit b7f450e41f
6 files changed
+222 -12

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.8.2');
define('APP_VERSION', '1.9.0');
// 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.8.2",
"version": "1.9.0",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+75
View File
@@ -1224,3 +1224,78 @@ body.anim-slow .board-card.card-pulse-local {
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
/* Sync + autosync combo: the pair reads as one button.
- At rest: Sync has no right border, autosync has no left border —
only the outer rectangle remains.
- Hover Sync: the right border comes back in accent so Sync gets a
complete outline around itself.
- Hover autosync: no accent border at all — instead a background
fill so the pair doesn't end up with a mismatched half-outline. */
.header-sync-group { align-items: stretch; }
.header-sync-group > button:first-child {
border-right: 0 !important;
}
.header-sync-group > button:first-child:hover {
border-right: 1px solid var(--t-accent) !important;
}
.header-sync-group > button + button {
border-left: 0 !important;
align-self: stretch;
}
#header-autosync:hover {
background: var(--t-accent);
border-color: var(--t-accent) !important;
color: var(--t-bg) !important;
}
/* Autosync toggle button.
Border stays neutral in both states — the dot carries the status
signal (accent + pulse when on, grey when off). Hover styling is
handled by the bg-fill rule further down. */
#header-autosync {
border-color: var(--t-border);
color: var(--t-muted);
}
.header-autosync-dot {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--t-accent);
transition: background 0.15s, transform 0.2s;
}
#header-autosync[data-autosync="off"] .header-autosync-dot {
background: var(--t-dim);
box-shadow: none;
}
/* Hover: button fills with solid accent (matching the Sync button's
hover border color). Dot switches to the app background color so it
reads as a knockout against the fill. Pulsing keeps tracking real
status — when a sync fires during hover the ring still animates. */
#header-autosync:hover .header-autosync-dot {
background: var(--t-bg);
}
/* Pulse shadow is accent-colored too, which would be invisible against
the accent fill. Swap it to a bg-toned ring during hover so the
pulse stays visible. */
#header-autosync:hover .header-autosync-dot.pulsing {
animation: header-autosync-pulse-on-hover 0.9s ease-out 1;
}
@keyframes header-autosync-pulse-on-hover {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-bg) 75%, transparent); transform: scale(1); }
40% { transform: scale(1.4); }
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-bg) 0%, transparent); transform: scale(1); }
}
/* Burst pulse on each sync. JS adds `.pulsing` at the start of a sync
and removes it after the animation finishes. Tuned to be visible
against the button background — a generous ring that fades out. */
.header-autosync-dot.pulsing {
animation: header-autosync-pulse 0.9s ease-out 1;
}
@keyframes header-autosync-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-accent) 75%, transparent); transform: scale(1); }
40% { transform: scale(1.4); }
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-accent) 0%, transparent); transform: scale(1); }
}
+30 -6
View File
@@ -17,10 +17,20 @@ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
}
// ── Session ──
//
// 30-day persistence: single-user local-first app, no reason to log the
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
// server-side GC are set to the same duration so the session survives
// reboots AND page reloads, up to a month of inactivity.
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
]);
session_start();
@@ -1301,11 +1311,16 @@ if (str_starts_with($route, '/api/')) {
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="sync now">
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
</button>
<div class="header-sync-group flex items-center">
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="sync now">
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
</button>
<button id="header-autosync" class="flex items-center justify-center px-2 py-1.5 text-xs border border-t-border transition-colors" data-tip="auto-sync">
<span id="header-autosync-dot" class="header-autosync-dot"></span>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
@@ -1815,6 +1830,15 @@ if (str_starts_with($route, '/api/')) {
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
</div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
<div class="border-t border-t-border pt-4 mt-4">
<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>
</div>
</div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
+92 -1
View File
@@ -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();
@@ -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