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

+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' });