feat: structured @-mentions (notify+clickable+hover), distinct links, Ctrl-rebalance sliders
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,12 @@ let tasks = [];
|
||||
let atomizerUp = true;
|
||||
const selected = new Set();
|
||||
|
||||
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
|
||||
let ctrlHeld = false;
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
|
||||
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
|
||||
window.addEventListener('blur', () => { ctrlHeld = false; });
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
|
||||
wrap.className = 'stack mt';
|
||||
|
||||
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
||||
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
|
||||
const hasExtension = kids.some((k) => k.origin === 'extended');
|
||||
if (subdivided.length) {
|
||||
const ind = document.createElement('p');
|
||||
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
|
||||
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
|
||||
let ind = null;
|
||||
function updateSumIndicator() {
|
||||
if (!ind) return;
|
||||
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
|
||||
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
|
||||
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
||||
: bad ? ' — should be 1.00' : '');
|
||||
: bad ? ' — should be 1.00' : '') +
|
||||
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
|
||||
}
|
||||
if (subdivided.length) {
|
||||
ind = document.createElement('p');
|
||||
wrap.appendChild(ind);
|
||||
}
|
||||
|
||||
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
|
||||
</span>
|
||||
</div>`;
|
||||
const slider = row.querySelector('input[type=range]');
|
||||
const coeffEl = row.querySelector('[data-coeff]');
|
||||
const bountyEl = row.querySelector('[data-bounty]');
|
||||
if (k.origin === 'subdivided' && !slider.disabled) {
|
||||
subSiblings.push({ k, slider, coeffEl, bountyEl });
|
||||
}
|
||||
let sliderTimer = null;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
slider.addEventListener('input', () => {
|
||||
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
|
||||
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
|
||||
const newVal = parseFloat(slider.value);
|
||||
coeffEl.textContent = newVal.toFixed(2);
|
||||
bountyEl.textContent = (newVal * k.budget).toFixed(2);
|
||||
const changed = [{ k, value: newVal }];
|
||||
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
|
||||
// stays ~1.00 (increase one → the rest shrink proportionally).
|
||||
if (ctrlHeld && k.origin === 'subdivided') {
|
||||
const others = subSiblings.filter((s) => s.k.id !== k.id);
|
||||
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
|
||||
const targetOthers = Math.max(0, 1 - newVal);
|
||||
if (others.length && curOthers > 0) {
|
||||
const scale = targetOthers / curOthers;
|
||||
others.forEach((s) => {
|
||||
const max = parseFloat(s.slider.max);
|
||||
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
|
||||
s.slider.value = nv;
|
||||
s.coeffEl.textContent = nv.toFixed(2);
|
||||
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
|
||||
changed.push({ k: s.k, value: nv });
|
||||
});
|
||||
}
|
||||
updateSumIndicator();
|
||||
}
|
||||
clearTimeout(sliderTimer);
|
||||
sliderTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
|
||||
effortCoefficient: parseFloat(slider.value), version: k.version,
|
||||
});
|
||||
k.version = res.task.version;
|
||||
k.effortCoefficient = res.task.effortCoefficient;
|
||||
k.bounty = res.task.bounty;
|
||||
for (const ch of changed) {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
|
||||
effortCoefficient: ch.value, version: ch.k.version,
|
||||
});
|
||||
ch.k.version = res.task.version;
|
||||
ch.k.effortCoefficient = res.task.effortCoefficient;
|
||||
ch.k.bounty = res.task.bounty;
|
||||
}
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
||||
else toast(e.message, 'err');
|
||||
}
|
||||
}, 400);
|
||||
}, 450);
|
||||
});
|
||||
const sel = row.querySelector('[data-sel]');
|
||||
if (sel) {
|
||||
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
|
||||
const grand = childrenOf(k.id);
|
||||
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
||||
});
|
||||
updateSumIndicator();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user