tim plugin, chat bot sync notifications, test fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-14 19:32:17 +02:00
1 parent ca47052768
commit 3b7c7cf5ac
21 files changed
+827 -14

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.5.0');
define('APP_VERSION', '1.5.1');
// 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.3.5",
"version": "1.3.6",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+22
View File
@@ -892,6 +892,28 @@ if (str_starts_with($route, '/api/')) {
}
$pulled++;
// Send bot notification back to the sender so their chat shows the
// sync confirmation. Best effort — relay may be older and not have
// /api/chat/bot; we don't want to fail the poll on it.
$senderName = $msg['sender_username'] ?? '';
if ($senderName && $senderName !== $relayUser) {
$botBody = $relayUser . ' synced: ' . $title;
$bch = curl_init(rtrim($relayUrl, '/') . '/api/chat/bot');
curl_setopt_array($bch, [
CURLOPT_RETURNTRANSFER => 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));
+1
View File
@@ -273,6 +273,7 @@ registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: '<kbd>W</kbd>', label: 'watch', handler: () => window._watchToggle?.() });
registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: '<kbd>I</kbd>', label: 'tim (time calculator)', handler: () => window._timToggle?.() });
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => {
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
+11
View File
@@ -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)) {
+4
View File
@@ -203,6 +203,10 @@ register('search', 'search <query> — 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', () => {
+1 -1
View File
@@ -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()]);
});
+10 -3
View File
@@ -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);
+7 -3
View File
@@ -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);
});
+1
View File
@@ -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',
+330
View File
@@ -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]);
});
});
});
+9 -1
View File
@@ -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);
+38
View File
@@ -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`.
+10
View File
@@ -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"]
}
}
+85
View File
@@ -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;
}
+69
View File
@@ -0,0 +1,69 @@
<div id="tim-overlay" class="hidden">
<div class="tim-panel">
<div class="tim-header">
<i class="ph ph-clock text-t-accent"></i>
<h2>tim — time calculator</h2>
<span class="tim-spacer"></span>
<button id="tim-close" class="tim-btn-secondary"><i class="ph ph-x"></i> close</button>
</div>
<div class="tim-tabs">
<button class="tim-tab active" data-tim-tab="duration">Duration</button>
<button class="tim-tab" data-tim-tab="add">Add</button>
<button class="tim-tab" data-tim-tab="workday">Workday</button>
</div>
<div class="tim-tab-body" data-tim-panel="duration">
<p class="tim-hint">Duration between two times. Format HHMM, e.g. 0830-1715.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-dur-start">Start</label>
<input type="text" id="tim-dur-start" placeholder="0830" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-dur-end">End</label>
<input type="text" id="tim-dur-end" placeholder="1715" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-dur-result"></div>
</div>
<div class="tim-tab-body hidden" data-tim-panel="add">
<p class="tim-hint">Add a duration to a given time.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-add-time">Time</label>
<input type="text" id="tim-add-time" placeholder="0830" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-add-dur">Duration</label>
<input type="text" id="tim-add-dur" placeholder="0130" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-add-result"></div>
</div>
<div class="tim-tab-body hidden" data-tim-panel="workday">
<p class="tim-hint">End time to reach target work duration, given start, break start, and break end.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-wd-start">Start</label>
<input type="text" id="tim-wd-start" placeholder="0820" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-break-from">Break from</label>
<input type="text" id="tim-wd-break-from" placeholder="1200" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-break-back">Back from break</label>
<input type="text" id="tim-wd-break-back" placeholder="1245" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-target">Target</label>
<input type="text" id="tim-wd-target" placeholder="0800" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-wd-result"></div>
</div>
</div>
</div>
+178
View File
@@ -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 = '<i class="ph ph-clock text-sm"></i>';
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' });
+6
View File
@@ -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
+25 -1
View File
@@ -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) ?: [];
+11 -3
View File
@@ -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 `
<div class="chat-msg bot">
<div><i class="ph ph-robot"></i> ${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}</div>
</div>`;
}
return `
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
<div>${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
</div>
`).join('');
</div>`;
}).join('');
if (wasAtBottom || !quiet) {
container.scrollTop = container.scrollHeight;
+7
View File
@@ -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); }