Compare commits

...
4 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 c2485b1307 fix(editor): appointment bucket-skip detects {id:null} shells too
The calendar day-popup invokes openModal with `{id: null, title, body}`
rather than a bare `null`, so `!card` was false and the #termin-body
exemption never fired — new appointments still inherited the active
bucket. Key the "is new" check on `card?.id` instead so both entry
shapes are treated the same.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:10:17 +02:00
thandClaude Opus 4.6 b08bdbaeb2 tests: assert new appointments don't inherit the active bucket
Opens the editor twice against a real bucket as activeBucket: once
with a #termin-seeded body (should stay empty in inp-bucket) and
once with a plain body (should pick up the active bucket id).
Skipped on fresh DBs that have no buckets to inherit from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:10 +02:00
thandClaude Opus 4.6 0e3088db9a editor: new appointments never inherit the active bucket
The modal's default-bucket logic dropped new cards into the current
real bucket when opened from one. Calendar-created appointments
(body pre-seeded with #termin / #geburtstag) would then land in e.g.
"work" instead of living bucket-less as calendar entries should.
Detect the pre-filled tag on open and force inpBucket empty for
those; regular new cards still inherit the active bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:04:39 +02:00
thandClaude Opus 4.6 29f92dbf9e search: right-click a hit to move it to a bucket
Drag was the only way to move a search hit into a bucket, which meant
opening the main board to find a drop target first. Right-click on a
search-hit now opens a small bucket picker anchored at the cursor:
clicking a bucket PATCHes the card's bucket_id and re-runs the
current search so the row updates in place. Includes an "(unsorted)"
entry that clears the bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:02:51 +02:00
5 changed files with 121 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.15');
define('APP_VERSION', '1.9.17');
// 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.9.15",
"version": "1.9.17",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+11 -1
View File
@@ -226,8 +226,18 @@ export function openModal(card = null) {
inpTitle.value = card ? card.title : (isJournal ? today : '');
body.value = card ? card.body : '';
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
// New appointments (#termin / #geburtstag pre-filled from the
// calendar's day-popup template) must never inherit the active
// bucket — they belong to a calendar, not a kanban column. For
// every other new card, inherit the active real bucket if we're
// in one so the user doesn't have to re-pick it.
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
// from the calendar day-popup — both count as "new", so key on
// card?.id not the card ref itself.
const isNewAppointment = !card?.id && /#(termin|geburtstag)\b/.test(body.value);
inpBucket.value = card?.bucket_id ? card.bucket_id
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
: (isNewAppointment ? ''
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
selectColor(card ? card.color : '#38bdf8');
setDueDate(card?.due_date || '');
updateDoneToggle();
+70
View File
@@ -226,6 +226,66 @@ function hitHtml(card, idx, words) {
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
}
function openBucketPicker(cardId, x, y) {
document.querySelector('.search-bucket-picker')?.remove();
const picker = document.createElement('div');
picker.className = 'search-bucket-picker';
// Clamp to viewport so a click near the bottom-right doesn't flow
// off-screen — rendered invisibly first to measure, then placed.
picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`;
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;';
const addRow = (label, color, onClick) => {
const item = document.createElement('button');
item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
item.innerHTML = `<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(label)}</span>`;
item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; });
item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; });
item.addEventListener('click', onClick);
list.appendChild(item);
};
addRow('(unsorted)', 'transparent', async () => {
picker.remove();
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } });
await window._reloadAll();
search(input.value);
});
state.buckets.forEach(b => {
addRow(b.name, b.color || '#64748b', async () => {
picker.remove();
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } });
await window._reloadAll();
search(input.value);
});
});
picker.appendChild(list);
document.body.appendChild(picker);
// Place, then flip if we'd overflow the viewport.
const r = picker.getBoundingClientRect();
let left = x, top = y;
if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8);
if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8);
picker.style.left = left + 'px';
picker.style.top = top + 'px';
picker.style.visibility = '';
const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } };
const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } };
const cleanup = () => {
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('keydown', onEsc, true);
};
setTimeout(() => {
document.addEventListener('mousedown', onDown, true);
document.addEventListener('keydown', onEsc, true);
}, 0);
}
function bindHits() {
results.querySelectorAll('.search-hit').forEach(el => {
el.addEventListener('click', () => {
@@ -234,6 +294,16 @@ function bindHits() {
else { active = i; updateActive(); }
});
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
const id = el.dataset.cardId;
if (!id) return;
// Highlight the row so the user sees which card the picker
// will move — mirrors the active-on-click behaviour.
const i = parseInt(el.dataset.idx);
if (!isNaN(i)) { active = i; updateActive(); }
openBucketPicker(id, e.clientX, e.clientY);
});
// Drag: only non-group, non-child cards
if (el.draggable) {
+38
View File
@@ -72,3 +72,41 @@ describe('Editor: Card Create', () => {
expect(document.querySelector(`[data-id="${res.id}"]`)).toExist();
});
});
describe('Editor: new-appointment bucket default', () => {
// New appointments (body pre-seeded with #termin / #geburtstag from
// the calendar day-popup template) must not inherit the currently
// active real bucket — they belong to a calendar. Regular new
// cards keep inheriting so the user doesn't have to re-pick.
afterEach(async () => {
// Close any open modal and restore the runner's baseline bucket.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(80);
window.state.activeBucket = 'all';
});
it('#termin body forces empty bucket; plain body inherits active', async () => {
const buckets = await fetch('api/buckets').then(r => r.json());
if (!Array.isArray(buckets) || !buckets.length) return; // nothing to inherit from
const b = buckets[0];
window.state.activeBucket = b.id;
// Appointment-flavoured body → inp-bucket stays empty.
window._openModal({ id: null, title: '9999-01-01 — probe', body: '#termin', color: '#fb923c', bucket_id: null });
await sleep(80);
const apptBucket = $('#inp-bucket').value;
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(120);
// Plain body → inp-bucket inherits the active bucket id.
window._openModal({ id: null, title: '__test_inherit__', body: '', color: '#38bdf8', bucket_id: null });
await sleep(80);
const regBucket = $('#inp-bucket').value;
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(120);
expect(apptBucket).toBe('');
expect(regBucket).toBe(String(b.id));
});
});