Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
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', async () => {
|
|
openTim();
|
|
await sleep(30);
|
|
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]);
|
|
});
|
|
});
|
|
});
|