diff --git a/config.php b/config.php index 0c61f85..abe09b8 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $botBody]), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Relay-User: ' . $relayUser, + 'X-Relay-Key: ' . $relayKey, + ], + CURLOPT_TIMEOUT => 5, + ]); + curl_exec($bch); + curl_close($bch); + } + // Download attachments if ($relayUid) { $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid)); diff --git a/public/js/app.js b/public/js/app.js index 25474df..721cd87 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -273,6 +273,7 @@ registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: 'T registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: 'C', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } }); registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: 'M', label: 'mail (plugin)' }); registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: 'W', label: 'watch', handler: () => window._watchToggle?.() }); +registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: 'I', label: 'tim (time calculator)', handler: () => window._timToggle?.() }); registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '/', label: 'search', handler: () => openSearch() }); registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '⇥ / ⇧ ⇥', label: 'cycle buckets', handler: (e) => { const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]; diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index 12c6214..fd1b2a3 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -648,6 +648,17 @@ function collabSyncCards(PDO $db): array { $stats['pulled']++; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; + // Notify the card's author that we synced it (best-effort; bot + // chat message, rendered in orange on the author's side). Only + // fires for cards authored by someone else. + if ($foreignAuthor && !empty($c['author_username'])) { + $cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)'; + collabRelayCall('/api/chat/bot', 'POST', [ + 'recipient' => $c['author_username'], + 'body' => $selfUser . ' synced: ' . $cardTitle, + ]); + } + // Fetch missing attachments referenced in the body. $pulledBody = $fields['body'] ?? ''; if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) { diff --git a/public/plugins/terminal/terminal.js b/public/plugins/terminal/terminal.js index 1b181bf..3368c7a 100644 --- a/public/plugins/terminal/terminal.js +++ b/public/plugins/terminal/terminal.js @@ -203,6 +203,10 @@ register('search', 'search — open search with query', (args) => { }); register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); }); +register('tim', 'open time calculator', () => { + if (window._timToggle) { window._timToggle(); print('tim opened', 'ok'); } + else print('tim plugin not loaded', 'err'); +}); register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); }); register('export', 'download export ZIP', () => { diff --git a/public/plugins/tests/calendar.test.js b/public/plugins/tests/calendar.test.js index 5ae4780..13270a6 100644 --- a/public/plugins/tests/calendar.test.js +++ b/public/plugins/tests/calendar.test.js @@ -47,7 +47,7 @@ describe('Calendar', () => { await sleep(200); click('#cal-today'); await sleep(200); - const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]); }); diff --git a/public/plugins/tests/card-interactions.test.js b/public/plugins/tests/card-interactions.test.js index d53ef17..8fbce15 100644 --- a/public/plugins/tests/card-interactions.test.js +++ b/public/plugins/tests/card-interactions.test.js @@ -392,11 +392,18 @@ describe('Card: Group Delete', () => { if (dialog?.classList.contains('open')) { const okBtn = document.getElementById('dialog-ok'); if (okBtn) click(okBtn); - await sleep(500); } - // Confirm both are gone - let cards = await fetch('api/cards').then(r => r.json()); + // Poll until deletion lands (can take a while on slow machines with + // confirm-dialog + two DELETE calls chained). + let cards; + const start = Date.now(); + while (Date.now() - start < 4000) { + cards = await fetch('api/cards').then(r => r.json()); + const stillThere = cards.some(c => c.id === parent.id || c.id === child.id); + if (!stillThere) break; + await sleep(100); + } expect(cards.some(c => c.id === parent.id)).toBe(false); expect(cards.some(c => c.id === child.id)).toBe(false); diff --git a/public/plugins/tests/terminal.test.js b/public/plugins/tests/terminal.test.js index 420a8a0..7b18b19 100644 --- a/public/plugins/tests/terminal.test.js +++ b/public/plugins/tests/terminal.test.js @@ -140,9 +140,13 @@ describe('Terminal: Commands', () => { termToggle(); await sleep(50); termExec('new __term_new_card__'); - await sleep(400); - const out = termOutput(); - expect(out).toContain('created'); + // termExec fires off an async API call + full reload; poll rather + // than sleeping a fixed amount so slow machines don't flake. + const start = Date.now(); + while (!termOutput().includes('created') && Date.now() - start < 3000) { + await sleep(50); + } + expect(termOutput()).toContain('created'); const cards = await fetch('api/cards').then(r => r.json()); expect(cards.some(c => c.title === '__term_new_card__')).toBe(true); }); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index 9a945a6..1014f2d 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -39,6 +39,7 @@ const FILES = [ './editor-toolbar.test.js', './line-numbers.test.js', './canvas.test.js', + './tim.test.js', './snippets.test.js', './image-fullscreen.test.js', './markdown.test.js', diff --git a/public/plugins/tests/tim.test.js b/public/plugins/tests/tim.test.js new file mode 100644 index 0000000..fddf77d --- /dev/null +++ b/public/plugins/tests/tim.test.js @@ -0,0 +1,330 @@ +import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4'; + +// ── Helpers ── + +function openTim() { + window._timToggle?.(); +} +function closeTim() { + const overlay = $('#tim-overlay'); + if (overlay && !overlay.classList.contains('hidden')) window._timToggle?.(); +} +function isOpen() { + return !$('#tim-overlay').classList.contains('hidden'); +} +function setInput(id, value) { + const el = $('#' + id); + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); +} +function activateTab(name) { + const tab = document.querySelector(`.tim-tab[data-tim-tab="${name}"]`); + tab.click(); +} + +// ── Registration ── + +describe('Tim Plugin: Registration', () => { + it('plugin is registered with hyprcrd', () => { + const meta = window.hyprcrd.plugins.find(p => p.name === 'tim'); + expect(meta).toExist(); + }); + + it('tim editor command is registered', () => { + expect(window.hyprcrd.editor.getCommand('tim')).toExist(); + }); + + it('toolbar clock button is injected', () => { + const btn = document.querySelector('#md-toolbar [data-md="tim"]'); + expect(btn).toExist(); + }); + + it('window._timToggle is exposed', () => { + expect(typeof window._timToggle).toBe('function'); + }); +}); + +// ── Structure ── + +describe('Tim Plugin: Structure', () => { + it('overlay and panel exist in DOM', () => { + expect($('#tim-overlay')).toExist(); + expect(document.querySelector('.tim-panel')).toExist(); + }); + + it('overlay is hidden initially', () => { + expect($('#tim-overlay').classList.contains('hidden')).toBe(true); + }); + + it('all three tabs exist', () => { + expect(document.querySelector('.tim-tab[data-tim-tab="duration"]')).toExist(); + expect(document.querySelector('.tim-tab[data-tim-tab="add"]')).toExist(); + expect(document.querySelector('.tim-tab[data-tim-tab="workday"]')).toExist(); + }); + + it('all inputs + result fields exist', () => { + const ids = [ + 'tim-dur-start', 'tim-dur-end', 'tim-dur-result', + 'tim-add-time', 'tim-add-dur', 'tim-add-result', + 'tim-wd-start', 'tim-wd-break-from', 'tim-wd-break-back', + 'tim-wd-target', 'tim-wd-result', + ]; + for (const id of ids) expect($('#' + id)).toExist(); + }); + + it('target input has no default value (placeholder only)', () => { + expect($('#tim-wd-target').value).toBe(''); + expect($('#tim-wd-target').placeholder).toBe('0800'); + }); +}); + +// ── Open / Close ── + +describe('Tim Plugin: Open & Close', () => { + afterEach(async () => { closeTim(); await closeAll(); }); + + it('toolbar button opens tim', async () => { + const btn = document.querySelector('#md-toolbar [data-md="tim"]'); + btn.click(); + await sleep(30); + expect(isOpen()).toBe(true); + }); + + it('_timToggle toggles visibility', async () => { + openTim(); await sleep(30); + expect(isOpen()).toBe(true); + openTim(); await sleep(30); + expect(isOpen()).toBe(false); + }); + + it('close button hides overlay', async () => { + openTim(); await sleep(30); + click('#tim-close'); + await sleep(30); + expect(isOpen()).toBe(false); + }); + + it('Escape closes overlay', async () => { + openTim(); await sleep(30); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await sleep(30); + expect(isOpen()).toBe(false); + }); + + it('clicking backdrop closes overlay', async () => { + openTim(); await sleep(30); + const overlay = $('#tim-overlay'); + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Backdrop-click requires e.target === overlay, which happens naturally + // for a click on the overlay itself. + await sleep(30); + expect(isOpen()).toBe(false); + }); +}); + +// ── Tab switching ── + +describe('Tim Plugin: Tabs', () => { + afterEach(async () => { closeTim(); await closeAll(); }); + + it('duration tab is active on load', () => { + const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]'); + expect(tab.classList.contains('active')).toBe(true); + }); + + it('clicking Add tab activates it and shows its panel', async () => { + openTim(); await sleep(30); + activateTab('add'); + await sleep(20); + expect(document.querySelector('.tim-tab[data-tim-tab="add"]').classList.contains('active')).toBe(true); + expect(document.querySelector('[data-tim-panel="add"]').classList.contains('hidden')).toBe(false); + expect(document.querySelector('[data-tim-panel="duration"]').classList.contains('hidden')).toBe(true); + }); + + it('switching to Workday reveals all four workday inputs', async () => { + openTim(); await sleep(30); + activateTab('workday'); + await sleep(20); + const panel = document.querySelector('[data-tim-panel="workday"]'); + expect(panel.classList.contains('hidden')).toBe(false); + expect(panel.querySelectorAll('input').length).toBe(4); + }); +}); + +// ── Duration calculation ── + +describe('Tim Plugin: Duration Calc', () => { + afterEach(async () => { closeTim(); await closeAll(); }); + + it('0830 → 1715 is 8h 45m', async () => { + openTim(); await sleep(30); + setInput('tim-dur-start', '0830'); + setInput('tim-dur-end', '1715'); + expect($('#tim-dur-result').textContent).toBe('8h 45m'); + }); + + it('wraps past midnight (2200 → 0200 = 4h 00m)', async () => { + openTim(); await sleep(30); + setInput('tim-dur-start', '2200'); + setInput('tim-dur-end', '0200'); + expect($('#tim-dur-result').textContent).toBe('4h 00m'); + }); + + it('colon format (08:30) is accepted', async () => { + openTim(); await sleep(30); + setInput('tim-dur-start', '08:30'); + setInput('tim-dur-end', '09:00'); + expect($('#tim-dur-result').textContent).toBe('0h 30m'); + }); + + it('empty input clears the result', async () => { + openTim(); await sleep(30); + setInput('tim-dur-start', '0800'); + setInput('tim-dur-end', '0900'); + expect($('#tim-dur-result').textContent).toBe('1h 00m'); + setInput('tim-dur-end', ''); + expect($('#tim-dur-result').textContent).toBe(''); + }); + + it('invalid input (minutes >= 60) clears the result', async () => { + openTim(); await sleep(30); + setInput('tim-dur-start', '0880'); + setInput('tim-dur-end', '1000'); + expect($('#tim-dur-result').textContent).toBe(''); + }); +}); + +// ── Add calculation ── + +describe('Tim Plugin: Add Calc', () => { + afterEach(async () => { closeTim(); await closeAll(); }); + + it('0830 + 0130 = 1000', async () => { + openTim(); await sleep(30); + activateTab('add'); await sleep(10); + setInput('tim-add-time', '0830'); + setInput('tim-add-dur', '0130'); + expect($('#tim-add-result').textContent).toBe('1000'); + }); + + it('wraps around 24h (2330 + 0100 = 0030)', async () => { + openTim(); await sleep(30); + activateTab('add'); await sleep(10); + setInput('tim-add-time', '2330'); + setInput('tim-add-dur', '0100'); + expect($('#tim-add-result').textContent).toBe('0030'); + }); +}); + +// ── Workday calculation ── + +describe('Tim Plugin: Workday Calc', () => { + afterEach(async () => { closeTim(); await closeAll(); }); + + it('start 0800, break 1200→1245, target default 8h → 1645', async () => { + openTim(); await sleep(30); + activateTab('workday'); await sleep(10); + setInput('tim-wd-start', '0800'); + setInput('tim-wd-break-from', '1200'); + setInput('tim-wd-break-back', '1245'); + expect($('#tim-wd-result').textContent).toContain('End at 1645'); + }); + + it('custom target of 0400 → ends earlier', async () => { + openTim(); await sleep(30); + activateTab('workday'); await sleep(10); + setInput('tim-wd-start', '0800'); + setInput('tim-wd-break-from', '1000'); + setInput('tim-wd-break-back', '1015'); + setInput('tim-wd-target', '0400'); + // 2h before break + 2h after = 4h; 10:15 + 2h = 1215 + expect($('#tim-wd-result').textContent).toContain('End at 1215'); + }); + + it('break before start shows an error message', async () => { + openTim(); await sleep(30); + activateTab('workday'); await sleep(10); + setInput('tim-wd-start', '1000'); + setInput('tim-wd-break-from', '0900'); + setInput('tim-wd-break-back', '0930'); + expect($('#tim-wd-result').textContent).toContain('Break must be after start'); + expect($('#tim-wd-result').classList.contains('err')).toBe(true); + }); + + it('target already reached before break → reports the finish time', async () => { + openTim(); await sleep(30); + activateTab('workday'); await sleep(10); + setInput('tim-wd-start', '0800'); + setInput('tim-wd-break-from', '1800'); // more than 8h before break + setInput('tim-wd-break-back', '1815'); + setInput('tim-wd-target', '0800'); + expect($('#tim-wd-result').textContent).toContain('Target already reached'); + }); +}); + +// ── Focus selection + Tab cycling ── + +describe('Tim Plugin: Focus & Tab Cycling', () => { + // Previous describes (Add Calc / Workday Calc) leave their tab active, + // which leaves the duration panel `.hidden` and breaks our Tab-cycle + // handler's closest('.tim-tab-body:not(.hidden)') lookup. Reset first. + beforeEach(() => { + document.querySelectorAll('#tim-overlay input').forEach(i => { i.value = ''; }); + activateTab('duration'); + }); + afterEach(async () => { closeTim(); await closeAll(); }); + + // Spies HTMLInputElement.prototype.focus so we can detect which input + // the handler *tried* to focus without depending on document.activeElement + // (which is unreliable in some synthetic-event runs). + function withFocusSpy(fn) { + const orig = HTMLInputElement.prototype.focus; + let last = null; + HTMLInputElement.prototype.focus = function() { last = this; return orig.apply(this, arguments); }; + try { return fn(() => last); } finally { HTMLInputElement.prototype.focus = orig; } + } + + it('focusing an input with value selects the text', async () => { + openTim(); await sleep(30); + const el = $('#tim-dur-start'); + el.value = '0830'; + el.focus(); + el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + expect(el.selectionStart).toBe(0); + expect(el.selectionEnd).toBe(el.value.length); + }); + + it('Tab from last input wraps to the first within the active panel', async () => { + openTim(); await sleep(30); + const first = $('#tim-dur-start'); + const last = $('#tim-dur-end'); + withFocusSpy(getLast => { + last.focus(); + last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(getLast()).toBe(first); + }); + }); + + it('Shift+Tab from first wraps to the last within the active panel', async () => { + openTim(); await sleep(30); + const first = $('#tim-dur-start'); + const last = $('#tim-dur-end'); + withFocusSpy(getLast => { + first.focus(); + first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true })); + expect(getLast()).toBe(last); + }); + }); + + it('Tab cycling stays within the active tab (workday has 4 inputs)', async () => { + openTim(); await sleep(30); + activateTab('workday'); await sleep(10); + const inputs = [...document.querySelectorAll('[data-tim-panel="workday"] input')]; + expect(inputs.length).toBe(4); + withFocusSpy(getLast => { + inputs[3].focus(); + inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(getLast()).toBe(inputs[0]); + }); + }); +}); diff --git a/public/plugins/tests/undo.test.js b/public/plugins/tests/undo.test.js index 3fba79e..f1a8062 100644 --- a/public/plugins/tests/undo.test.js +++ b/public/plugins/tests/undo.test.js @@ -357,7 +357,15 @@ describe('Undo: No-Op When Empty', () => { ctrlZ(); await sleep(200); } - // Now Ctrl+Z should be harmless + // Wait for state to settle — previous undo operations can still be + // resurrecting cards asynchronously after canUndo() has flipped to + // false. Poll until two consecutive fetches agree on card count. + let prev = -1, stable = 0; + for (let i = 0; i < 30 && stable < 2; i++) { + const cards = await fetch('api/cards').then(r => r.json()); + if (cards.length === prev) stable++; else { stable = 0; prev = cards.length; } + if (stable < 2) await sleep(100); + } const cardsBefore = await fetch('api/cards').then(r => r.json()); ctrlZ(); await sleep(200); diff --git a/public/plugins/tim/README.md b/public/plugins/tim/README.md new file mode 100644 index 0000000..279eb2a --- /dev/null +++ b/public/plugins/tim/README.md @@ -0,0 +1,38 @@ +# tim plugin + +Tiny time calculator. Three tabs: duration between two times, add a duration to a time, and workday-end with a break. + +## Open + +- **`I`** on the board +- **Clock icon** in the card-edit toolbar +- Terminal: `tim` + +Close with **ESC** or the × button. + +## Tabs + +### Duration +Two HHMM inputs → how many hours and minutes between them. Wraps past midnight. + +### Add +Time + duration → resulting HHMM. Wraps around 24h. + +### Workday +Start, break from, break back, target (defaults to 08:00). Computes when the workday ends to hit the target, or tells you it's already reached. + +## Input format + +All inputs take HHMM-style strings: + +- `0830`, `830`, `08:30`, `8:30` → 08:30 +- Missing digits are left-padded with zeros +- Minute component must be < 60 + +Invalid input clears the result silently — no red flashes while typing. + +Calculation is live: the result updates as you type in each field. + +## Disabling + +Add `"tim"` to the `disabled` array in `data/plugins.json`. diff --git a/public/plugins/tim/plugin.json b/public/plugins/tim/plugin.json new file mode 100644 index 0000000..32811a8 --- /dev/null +++ b/public/plugins/tim/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tim", + "version": "1.0.0", + "description": "Time calculator — duration between two times, add duration to time, end-of-day for target work hours.", + "frontend": { + "js": ["tim.js"], + "css": ["tim.css"], + "html": ["tim.html"] + } +} diff --git a/public/plugins/tim/tim.css b/public/plugins/tim/tim.css new file mode 100644 index 0000000..157da60 --- /dev/null +++ b/public/plugins/tim/tim.css @@ -0,0 +1,85 @@ +#tim-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,0.55); + display: flex; align-items: center; justify-content: center; + padding: 40px; +} +#tim-overlay.hidden { display: none; } + +.tim-panel { + background: var(--t-surface); border: 1px solid var(--t-border); + border-radius: var(--ui-radius-modal, 4px); + box-shadow: 0 20px 60px rgba(0,0,0,0.6); + width: 100%; max-width: 600px; + display: flex; flex-direction: column; + overflow: hidden; +} + +.tim-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; border-bottom: 1px solid var(--t-border); +} +.tim-header h2 { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 12px; color: var(--t-accent); margin: 0; } +.tim-spacer { flex: 1; } +.tim-btn-secondary { + display: inline-flex; align-items: center; gap: 6px; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; + padding: 5px 12px; background: transparent; + border: 1px solid var(--t-border); color: var(--t-muted); cursor: pointer; + border-radius: var(--ui-radius-button); + transition: all 0.15s; +} +.tim-btn-secondary:hover { border-color: var(--t-danger); color: var(--t-danger); } + +.tim-tabs { display: flex; border-bottom: 1px solid var(--t-border); } +.tim-tab { + flex: 1; padding: 10px 16px; background: transparent; border: none; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; + color: var(--t-muted); cursor: pointer; transition: all 0.15s; + border-bottom: 2px solid transparent; +} +.tim-tab:hover { color: var(--t-bright); } +.tim-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); } + +.tim-tab-body { padding: 20px; } +.tim-tab-body.hidden { display: none; } + +.tim-hint { + font-size: 11px; color: var(--t-dim); + margin-bottom: 16px; line-height: 1.5; +} + +.tim-fields { + display: flex; justify-content: center; flex-wrap: wrap; + gap: 20px 28px; margin-bottom: 16px; +} +.tim-field { + display: flex; flex-direction: column; align-items: center; gap: 6px; +} +.tim-field label { + font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 9px; + color: var(--t-dim); text-transform: uppercase; letter-spacing: 0.5px; +} +.tim-field input { + background: var(--t-bg); border: 1px solid var(--t-border); + color: var(--t-bright); padding: 6px 10px; + border-radius: var(--ui-radius-input); + font-family: var(--t-font-content, monospace); font-size: 13px; + width: 110px; text-align: center; letter-spacing: 2px; + outline: none; transition: border-color 0.15s; +} +.tim-field input:focus { border-color: var(--t-accent); } + +.tim-result { + margin-top: 16px; padding: 14px 16px; + border: 1px solid var(--t-border); border-radius: 4px; + font-family: var(--t-font-content, monospace); font-size: 16px; + color: var(--t-bright); text-align: center; + min-height: 48px; display: flex; align-items: center; justify-content: center; + background: var(--t-bg); +} +.tim-result.ok { border-color: var(--t-accent); color: var(--t-accent); } +.tim-result.err { border-color: var(--t-danger); color: var(--t-danger); font-size: 11px; } +.tim-result:empty::before { + content: '—'; color: var(--t-dim); opacity: 0.3; +} diff --git a/public/plugins/tim/tim.html b/public/plugins/tim/tim.html new file mode 100644 index 0000000..8c91eb9 --- /dev/null +++ b/public/plugins/tim/tim.html @@ -0,0 +1,69 @@ + diff --git a/public/plugins/tim/tim.js b/public/plugins/tim/tim.js new file mode 100644 index 0000000..839902e --- /dev/null +++ b/public/plugins/tim/tim.js @@ -0,0 +1,178 @@ +// ── tim — time calculator plugin ── + +const $ = id => document.getElementById(id); +const h = window.hyprcrd; +const overlay = $('tim-overlay'); + +function show() { overlay?.classList.remove('hidden'); $('tim-dur-start')?.focus(); } +function hide() { overlay?.classList.add('hidden'); } +function toggle() { overlay?.classList.contains('hidden') ? show() : hide(); } + +function pad4(n) { return String(n).padStart(4, '0'); } + +// Parse HHMM string → total minutes since midnight, or null on invalid +function parseHHMM(s) { + if (!s) return null; + s = String(s).trim().replace(':', '').padStart(4, '0'); + if (!/^\d{3,4}$/.test(s)) return null; + const h = parseInt(s.slice(0, -2)); + const m = parseInt(s.slice(-2)); + if (m >= 60) return null; + return h * 60 + m; +} + +function minutesToHHMM(mins) { + mins = ((mins % (24 * 60)) + 24 * 60) % (24 * 60); + return pad4(Math.floor(mins / 60) * 100 + (mins % 60)); +} + +function formatDuration(mins) { + if (mins < 0) mins += 24 * 60; + const h = Math.floor(mins / 60), m = mins % 60; + return `${h}h ${String(m).padStart(2, '0')}m`; +} + +// ── Tab switching ── + +document.querySelectorAll('.tim-tab').forEach(t => { + t.addEventListener('click', () => { + document.querySelectorAll('.tim-tab').forEach(x => x.classList.toggle('active', x === t)); + document.querySelectorAll('.tim-tab-body').forEach(p => { + p.classList.toggle('hidden', p.dataset.timPanel !== t.dataset.timTab); + }); + const firstInput = document.querySelector(`[data-tim-panel="${t.dataset.timTab}"] input`); + firstInput?.focus(); + }); +}); + +// ── Duration tab ── + +function calcDuration() { + const res = $('tim-dur-result'); + const s = parseHHMM($('tim-dur-start').value); + const e = parseHHMM($('tim-dur-end').value); + if (s == null || e == null) { + res.className = 'tim-result'; + res.textContent = ''; + return; + } + const diff = e - s; + res.className = 'tim-result ok'; + res.textContent = formatDuration(diff); +} + +// ── Add tab ── + +function calcAdd() { + const res = $('tim-add-result'); + const t = parseHHMM($('tim-add-time').value); + const d = parseHHMM($('tim-add-dur').value); + if (t == null || d == null) { + res.className = 'tim-result'; + res.textContent = ''; + return; + } + res.className = 'tim-result ok'; + res.textContent = minutesToHHMM(t + d); +} + +// ── Workday tab ── + +function calcWorkday() { + const res = $('tim-wd-result'); + const s = parseHHMM($('tim-wd-start').value); + const bf = parseHHMM($('tim-wd-break-from').value); + const bb = parseHHMM($('tim-wd-break-back').value); + const target = parseHHMM($('tim-wd-target').value) ?? 480; + + if (s == null || bf == null || bb == null) { + res.className = 'tim-result'; + res.textContent = ''; + return; + } + const workedBeforeBreak = bf - s; + if (workedBeforeBreak < 0) { + res.className = 'tim-result err'; + res.textContent = 'Break must be after start'; + return; + } + const remaining = target - workedBeforeBreak; + if (remaining < 0) { + res.className = 'tim-result ok'; + res.textContent = 'Target already reached at ' + minutesToHHMM(s + target); + return; + } + const endOfDay = (bb + remaining) % (24 * 60); + res.className = 'tim-result ok'; + res.textContent = `End at ${minutesToHHMM(endOfDay)} (${formatDuration(remaining)} left after break)`; +} + +// Single delegated input handler on the overlay — routes to the right +// calc by input id. Robust against DOM order / re-renders. +overlay?.addEventListener('input', e => { + const id = e.target?.id || ''; + if (id.startsWith('tim-dur-')) calcDuration(); + else if (id.startsWith('tim-add-')) calcAdd(); + else if (id.startsWith('tim-wd-')) calcWorkday(); +}); + +// Focus → select existing text so typing replaces it. +overlay?.addEventListener('focusin', e => { + if (e.target.tagName === 'INPUT' && e.target.value) e.target.select(); +}); + +// Tab cycles within the currently active tab panel's inputs. +overlay?.addEventListener('keydown', e => { + if (e.key !== 'Tab') return; + const panel = e.target.closest('.tim-tab-body:not(.hidden)'); + if (!panel) return; + const inputs = [...panel.querySelectorAll('input')]; + if (!inputs.length) return; + const idx = inputs.indexOf(e.target); + if (idx === -1) return; + e.preventDefault(); + const next = e.shiftKey + ? (idx - 1 + inputs.length) % inputs.length + : (idx + 1) % inputs.length; + inputs[next].focus(); +}); + +// ── Global bindings ── + +$('tim-close')?.addEventListener('click', hide); +overlay?.addEventListener('click', e => { if (e.target === overlay) hide(); }); + +window.addEventListener('keydown', e => { + if (e.key === 'Escape' && !overlay?.classList.contains('hidden')) { + e.preventDefault(); e.stopPropagation(); + hide(); + } +}, { capture: true }); + +// Expose toggle for terminal / shortcuts +window._timToggle = toggle; + +// Register toolbar command — the md-toolbar delegated click handler in +// editor.js routes clicks on [data-md="tim"] through this. +h?.editor?.registerCommand('tim', () => toggle()); + +function injectToolbarButton() { + const toolbar = document.getElementById('md-toolbar'); + if (!toolbar) return; + if (toolbar.querySelector('[data-md="tim"]')) return; + const btn = document.createElement('button'); + btn.setAttribute('data-md', 'tim'); + btn.setAttribute('data-help', 'tim — time calculator\nDuration, add, workday end.\nShortcut: I'); + btn.className = 'toolbar-btn'; + btn.innerHTML = ''; + const spacer = toolbar.querySelector('.flex-1'); + if (spacer) toolbar.insertBefore(btn, spacer); + else toolbar.appendChild(btn); +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectToolbarButton); +} else { + injectToolbarButton(); +} + +h?.registerPlugin({ name: 'tim', version: '1.0.0' }); diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index d4a3601..180597f 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -189,6 +189,12 @@ $chatColNames = array_column($chatCols, 'name'); if (!in_array('seen', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); } +// Bot notifications: automated sync confirmations ("alice synced: My Card"). +// `bot` column = 1 means the UI renders it as a system notification in the +// orange accent color instead of a regular chat bubble. +if (!in_array('bot', $chatColNames)) { + $db->exec('ALTER TABLE chat_messages ADD COLUMN bot INTEGER NOT NULL DEFAULT 0'); +} $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); // Collaborative buckets: shared among multiple tenants. Each bucket has one diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php index 0e2da47..903a094 100644 --- a/relay/inc/routes/chat.php +++ b/relay/inc/routes/chat.php @@ -62,7 +62,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404); $peerId = $peerTenant['id']; $stmt = $db->prepare(' - SELECT id, sender_id, sender_username, body, created_at, seen + SELECT id, sender_id, sender_username, body, created_at, seen, bot FROM chat_messages WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s) ORDER BY created_at ASC LIMIT 500 @@ -78,6 +78,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'seen' => (int)$row['seen'], + 'bot' => (int)($row['bot'] ?? 0), ]; } // Mark incoming as seen @@ -106,6 +107,29 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) { $clientTenant = $clientTenant ?? authClient($db); +// Bot notification: client announces "I synced a card" to the sender. +// No encryption — body is a short system-generated string like +// "alice synced: Card Title". bot=1 flags it so the UI renders in +// the orange accent color. +if ($route === '/api/chat/bot' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $body = trim((string)($input['body'] ?? '')); + $recipientName = trim((string)($input['recipient'] ?? '')); + if ($body === '') jsonResponse(['error' => 'body required'], 400); + if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); + if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // no self-notifications + $recipient = getTenantByUsername($db, $recipientName); + if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); + $stored = clientEncrypt($body, $interKey); + $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, bot) VALUES (:r, :s, :sn, :b, 1)') + ->execute(['r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored]); + jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); +} + + if ($route === '/api/chat' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; diff --git a/relay/ui/app.js b/relay/ui/app.js index 0754255..2a1e5f4 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -719,12 +719,20 @@ async function loadChatHistory(peer, quiet = false) { return; } - container.innerHTML = messages.map(m => ` + container.innerHTML = messages.map(m => { + if (m.bot) { + return ` +
+
${escapeHtml(m.body)}
+
${formatChatTime(m.created_at)}
+
`; + } + return `
${escapeHtml(m.body)}
${formatChatTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
-
- `).join(''); + `; + }).join(''); if (wasAtBottom || !quiet) { container.scrollTop = container.scrollHeight; diff --git a/relay/ui/style.css b/relay/ui/style.css index 148c02f..546bb0e 100644 --- a/relay/ui/style.css +++ b/relay/ui/style.css @@ -280,6 +280,13 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point } .chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; } .chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; } +.chat-msg.bot { + align-self: center; max-width: 90%; + background: rgba(251, 146, 60, 0.12); color: #fb923c; + border: 1px solid rgba(251, 146, 60, 0.35); + font-size: 11px; text-align: center; +} +.chat-msg.bot .msg-time { text-align: center; } .chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; } .chat-msg.mine .msg-time { text-align: right; } .chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }