diff --git a/config.php b/config.php
index a0c64f3..9bdefd4 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
+
+
+
diff --git a/public/css/style.css b/public/css/style.css
index d782e29..440fab2 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -395,6 +395,7 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
@keyframes spin { to { transform: rotate(360deg); } }
+
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
@@ -402,7 +403,7 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
-.card-spinner { animation: spin 0.8s linear infinite; }
+.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; }
/* Cards */
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..cfa8719
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/public/index.php b/public/index.php
index 0e40b1f..bdf1955 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1159,6 +1159,7 @@ if (str_starts_with($route, '/api/')) {
hyprcrd
+
@@ -1927,6 +1928,7 @@ if (str_starts_with($route, '/api/')) {
Auto-save on close (editor)
Switch to card's bucket on open (search / link)
+ Show shopping list items in task collector
diff --git a/public/js/modules/tasks.js b/public/js/modules/tasks.js
index 3f69d2f..999a12e 100644
--- a/public/js/modules/tasks.js
+++ b/public/js/modules/tasks.js
@@ -13,8 +13,13 @@ const summary = $('tasks-summary');
const countBadge = $('tasks-open-count');
function collectTasks() {
+ const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
const all = [];
for (const card of state.cards) {
+ if (!showShopping) {
+ const tags = (card.tags || '').split(',').map(s => s.trim());
+ if (tags.includes('einkauf') || tags.includes('shopping')) continue;
+ }
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
let m, idx = 0;
while ((m = re.exec(card.body || '')) !== null) {
diff --git a/public/js/modules/theme.js b/public/js/modules/theme.js
index c383677..9834a0b 100644
--- a/public/js/modules/theme.js
+++ b/public/js/modules/theme.js
@@ -451,6 +451,7 @@ function initBehaviorSettings() {
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
+ 'setting-tasks-show-shopping': { key: 'hyprcrd-tasks-show-shopping', def: '0' },
};
for (const [id, cfg] of Object.entries(selects)) {
const el = $(id);
diff --git a/public/plugins/shopping/shopping.js b/public/plugins/shopping/shopping.js
index 0b3eecb..e22734a 100644
--- a/public/plugins/shopping/shopping.js
+++ b/public/plugins/shopping/shopping.js
@@ -241,7 +241,7 @@ function openDialog() {
const prev = getActiveIds();
const next = [...selected];
setActiveIds(next);
- status.textContent = 'saving…';
+ status.innerHTML = ' saving — please wait!';
try {
// Remove deactivated lists from relay.
for (const id of prev) {
diff --git a/public/plugins/tests/tim.test.js b/public/plugins/tests/tim.test.js
index fddf77d..87ee666 100644
--- a/public/plugins/tests/tim.test.js
+++ b/public/plugins/tests/tim.test.js
@@ -127,7 +127,9 @@ describe('Tim Plugin: Open & Close', () => {
describe('Tim Plugin: Tabs', () => {
afterEach(async () => { closeTim(); await closeAll(); });
- it('duration tab is active on load', () => {
+ it('duration tab is active on load', async () => {
+ openTim();
+ await sleep(30);
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
expect(tab.classList.contains('active')).toBe(true);
});
diff --git a/relay/favicon.svg b/relay/favicon.svg
new file mode 100644
index 0000000..cfa8719
--- /dev/null
+++ b/relay/favicon.svg
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/relay/inc/auth.php b/relay/inc/auth.php
index 94e55ae..2c67275 100644
--- a/relay/inc/auth.php
+++ b/relay/inc/auth.php
@@ -24,7 +24,9 @@ function getSessionTenant(PDO $db): ?array {
if (!$token) return null;
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
$stmt->execute(['tok' => $token]);
- return $stmt->fetch() ?: null;
+ $tenant = $stmt->fetch() ?: null;
+ if ($tenant) touchLastSeen($db, (int)$tenant['id']);
+ return $tenant;
}
function destroySession(PDO $db): void {
@@ -62,5 +64,11 @@ function authClient(PDO $db): ?array {
$tenant = getTenantByUsername($db, $username);
if (!$tenant) return null;
if (!password_verify($key, $tenant['secret_hash'])) return null;
+ touchLastSeen($db, (int)$tenant['id']);
return $tenant;
}
+
+function touchLastSeen(PDO $db, int $tenantId): void {
+ $db->prepare("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = :id")
+ ->execute(['id' => $tenantId]);
+}
diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php
index c552a5c..2f3edd4 100644
--- a/relay/inc/bootstrap.php
+++ b/relay/inc/bootstrap.php
@@ -133,6 +133,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS tenants (
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
+// last_seen_at: updated on every authenticated request so the admin
+// can see when a user was last active.
+$tenantCols = $db->query("PRAGMA table_info(tenants)")->fetchAll();
+$tenantColNames = array_column($tenantCols, 'name');
+if (!in_array('last_seen_at', $tenantColNames)) {
+ $db->exec("ALTER TABLE tenants ADD COLUMN last_seen_at TEXT");
+}
+
$db->exec('CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
diff --git a/relay/inc/routes/admin.php b/relay/inc/routes/admin.php
index ddfe6e2..f53da58 100644
--- a/relay/inc/routes/admin.php
+++ b/relay/inc/routes/admin.php
@@ -14,7 +14,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
if ($route === '/api/users' && $method === 'GET') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
- $rows = $db->query('SELECT id, username, is_admin, created_at FROM tenants ORDER BY id ASC')->fetchAll();
+ $rows = $db->query('SELECT id, username, is_admin, created_at, last_seen_at FROM tenants ORDER BY id ASC')->fetchAll();
jsonResponse(['users' => $rows]);
}
diff --git a/relay/ui/app.js b/relay/ui/app.js
index 1c1e3fd..f3d745e 100644
--- a/relay/ui/app.js
+++ b/relay/ui/app.js
@@ -68,14 +68,15 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
- ['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
+ ['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
- if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
+ if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose');
+ $('nav-inbox')?.classList.toggle('active', name === 'inbox');
$('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('nav-account').classList.toggle('active', name === 'account');
@@ -92,13 +93,19 @@ function showView(name) {
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
+ $('compose-body')?.focus();
+ }
+ if (name === 'inbox') {
loadInbox();
loadOutbox();
loadCollabInbox();
startInboxPoll();
- $('compose-body')?.focus();
} else {
stopInboxPoll();
+ // Keep the header badge count fresh even outside the inbox
+ // view — a quiet load updates the count without rendering the
+ // full list (the DOM elements are in the hidden inbox view).
+ loadInbox({ quiet: true });
}
if (name === 'chat') {
loadChatThreads();
@@ -114,7 +121,7 @@ function showView(name) {
}
// Remember the active view so a page reload lands back here
// instead of defaulting to compose every time.
- if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
+ if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
try { localStorage.setItem('relay-last-view', name); } catch {}
}
if (name === 'admin') {
@@ -139,7 +146,7 @@ function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (inboxBusy) return;
- if (!$('view-compose')?.classList.contains('hidden')) {
+ if (!$('view-inbox')?.classList.contains('hidden')) {
loadInbox({ quiet: true });
loadOutbox({ quiet: true });
loadCollabInbox({ quiet: true });
@@ -173,21 +180,7 @@ window.toggleNavMenu = toggleNavMenu;
* if at all (the inbox is already on screen). */
function jumpToInbox() {
if (!me) return;
- if ($('view-compose')?.classList.contains('hidden')) {
- showView('compose');
- }
- // Wait one frame so the (possibly just-shown) view has measurable
- // dimensions before scrollIntoView fires
- requestAnimationFrame(() => {
- const target = $('inbox-section');
- if (!target) return;
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- target.classList.remove('flash-highlight');
- // Restart the animation by toggling the class
- void target.offsetWidth;
- target.classList.add('flash-highlight');
- setTimeout(() => target.classList.remove('flash-highlight'), 1600);
- });
+ showView('inbox');
}
window.jumpToInbox = jumpToInbox;
// Tap outside the menu closes it
@@ -285,7 +278,7 @@ function routePostLogin(status) {
toast('set your client secret to start using the relay');
} else {
const last = localStorage.getItem('relay-last-view') || 'compose';
- showView(['compose', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
+ showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
}
}
@@ -641,7 +634,6 @@ async function loadCollabInbox({ quiet = false } = {}) {
${c.mine ? 'you' : escapeHtml(c.author_username || '—')}
${c.title ? escapeHtml(c.title) : '(untitled)'}
- ${c.preview ? `${escapeHtml(c.preview)}
` : ''}
${escapeHtml(formatRelTime(c.updated_at))}
@@ -1006,19 +998,22 @@ async function loadUsers() {
`;
return;
}
- list.innerHTML = users.map(u => `
+ list.innerHTML = users.map(u => {
+ const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
+ return `
${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''}
-
created ${u.created_at}
+
created ${u.created_at} · ${seenText}
${u.username !== me?.username ? `` : ''}
- `).join('');
+ `;
+ }).join('');
}
async function createUser() {
diff --git a/relay/ui/layout.php b/relay/ui/layout.php
index 418779a..9cf75d1 100644
--- a/relay/ui/layout.php
+++ b/relay/ui/layout.php
@@ -7,6 +7,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
hyprcrd relay
+
@@ -14,14 +15,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }