v2.0.8 — oumta timeline: continuous fading line, multi-select filter

- Connector: moved from per-entry pseudo-elements to a single
  linear-gradient line on the body. Fades in/out 28px at each end
  and no longer has gaps at entry boundaries. Dots and the date
  rect's solid background sit naturally on top; everything else
  stays transparent so the line threads through cleanly.
- Layout: entry is now a 2-row grid. The optional date rect lives
  in row 1 (justify-self:start, left-aligned), while the dot-row
  and the events column both pin to row 2 so the time label always
  lines up with the first event of the cluster — even when a date
  rect sits above. Time+dot `align-self: start` keeps them at the
  top of the event section instead of vertically centred through
  the whole cluster.
- Filters: state.timelineFilter is a Set now, so clicking multiple
  workload pills (or the overflow entries) stacks them as an
  any-of filter. "all" clears the set. The overflow dropdown stays
  open on pick so several can be toggled without reopening it each
  time. Scope label shows "filtered · N" with the active count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-18 17:21:18 +02:00
1 parent 9f90ea516a
commit 4aca21812e
4 files changed
+91 -36

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '2.0.7');
define('APP_VERSION', '2.0.8');
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
// Off by default; enable via env for local troubleshooting only.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "2.0.7",
"version": "2.0.8",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
@@ -254,24 +254,57 @@
/* Timeline body: two-column layout (left = date badge + time + dot, right
* = event list). Clusters of events that fired within the same tolerance
* window get a single row with all sub-events listed in the right column. */
* window get a single row with all sub-events listed in the right column.
*
* The decorative connector line is drawn ONCE on the body, not per entry —
* that's the only way to guarantee it's truly continuous (per-entry
* pseudo-elements collapse into gaps the moment an entry has a shorter
* left column than right). */
.oumta-timeline-body {
display: flex; flex-direction: column;
gap: 20px;
position: relative;
}
.oumta-timeline-body::before {
content: "";
position: absolute;
left: 79px; /* right edge of the 80px left column */
top: 24px; /* begin a bit below the first dot */
bottom: 24px; /* end a bit above the last dot */
width: 2px;
/* Soft fade at both ends so the line feels like it eases into the
* timeline rather than being clipped. The middle stays fully opaque. */
background: linear-gradient(
to bottom,
transparent 0,
var(--t-border) 28px,
var(--t-border) calc(100% - 28px),
transparent 100%
);
z-index: 0;
pointer-events: none;
}
.oumta-timeline-entry {
display: grid;
grid-template-columns: 64px 1fr;
gap: 14px;
padding: 10px 0 14px;
grid-template-columns: 80px 1fr;
grid-template-rows: auto auto; /* optional date row on top, dot-row + events below */
gap: 0 14px;
position: relative;
align-items: flex-start;
}
.oumta-timeline-entry + .oumta-timeline-entry { border-top: 1px solid var(--t-border); }
.oumta-tl-left {
display: flex; flex-direction: column;
.oumta-timeline-entry > * { position: relative; z-index: 1; }
/* Time + dot share a horizontal row so clusters without a date badge
* show the time label LEFT of the dot, not stacked underneath. The row
* lives in grid row 2 so events in the right column (also row 2) stay
* vertically aligned with it — regardless of whether a date badge
* occupies row 1 above. */
.oumta-tl-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 40px;
gap: 8px;
grid-column: 1;
grid-row: 2;
justify-self: end; /* hug the connector line on the right */
align-self: start; /* don't stretch to the events column's full height — stay at the top */
}
.oumta-tl-date {
display: flex; flex-direction: column;
@@ -281,7 +314,11 @@
border-radius: var(--ui-radius-card);
padding: 4px 0 5px;
width: 44px;
background: var(--t-bg);
background: var(--t-bg); /* solid, so the connector line behind doesn't strike through the text */
grid-column: 1;
grid-row: 1;
justify-self: start; /* badge hugs the left edge of the column */
margin-bottom: 8px; /* spacing before the dot-row below */
}
.oumta-tl-date .day {
font-family: var(--font-content, monospace);
@@ -306,7 +343,12 @@
.oumta-tl-dot {
width: 10px; height: 10px;
border-radius: 50%;
margin-top: 2px;
/* Dot sits at the right end of the time+dot row, which align-items:
* flex-end of the left column pushes to the right edge (x=80).
* Translate by half the dot width so its centre lines up exactly with
* the connector line (also at x=80). */
transform: translateX(5px);
flex-shrink: 0;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
background: var(--oumta-dot-color);
}
@@ -318,8 +360,10 @@
.oumta-tl-right {
display: flex; flex-direction: column;
gap: 8px;
gap: 23px;
min-width: 0;
grid-column: 2;
grid-row: 2; /* pinned to the same row as .oumta-tl-row so events line up with the dot */
}
.oumta-tl-event {
display: flex; flex-direction: column;
@@ -23,7 +23,7 @@ const state = {
pollHandle: null,
sseHandle: null,
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
timelineFilter: null, // null = all events, else workloadId (pill quick-pick)
timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills)
};
// ── Central API surface ─────────────────────────────────────────
@@ -314,14 +314,15 @@ function renderTimeline() {
scopeEl.textContent = 'events for ' + scope;
backEl.classList.remove('hidden');
} else {
scopeEl.textContent = state.timelineFilter ? 'filtered' : 'all events';
const n = state.timelineFilter.size;
scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`;
backEl.classList.add('hidden');
}
const filter = state.timelineFilter;
const events = state.data.timeline.filter(e => {
if (scope) return e.workloadId === scope;
if (filter) return e.workloadId === filter;
return true;
if (filter.size === 0) return true;
return filter.has(e.workloadId);
});
renderTimelineFilters();
@@ -345,10 +346,10 @@ function renderTimeline() {
</div>`
: '';
const left = `
<div class="oumta-tl-left">
${badge}
<div class="oumta-tl-dot" data-sev="${sev}"></div>
<div class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</div>
<div class="oumta-tl-row">
<span class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</span>
<span class="oumta-tl-dot" data-sev="${sev}"></span>
</div>`;
const right = `
<div class="oumta-tl-right">
@@ -370,14 +371,19 @@ function renderTimeline() {
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
}).join('');
// Tapping a workload name inside an event filters the timeline to it.
// Tapping a workload name inside an event toggles it in the filter set.
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
el.addEventListener('click', () => setTimelineFilter(el.dataset.wlid));
el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid));
});
}
function setTimelineFilter(workloadId) {
state.timelineFilter = workloadId || null;
/** Toggle a workload in the multi-select filter set. Passing an empty
* value clears the set (the "all" pill). */
function toggleTimelineFilter(workloadId) {
const set = state.timelineFilter;
if (!workloadId) { set.clear(); }
else if (set.has(workloadId)) { set.delete(workloadId); }
else { set.add(workloadId); }
renderTimeline();
}
@@ -402,26 +408,29 @@ function renderTimelineFilters() {
const INLINE_LIMIT = 6;
const inline = workloads.slice(0, INLINE_LIMIT);
const overflow = workloads.slice(INLINE_LIMIT);
const active = state.timelineFilter;
const active = state.timelineFilter; // Set<workloadId>
const pill = (id, label, isActive) =>
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
const overflowActive = overflow.includes(active);
// "all" is active whenever the filter set is empty; picking it clears
// the set. The individual pills toggle their workload in the set so
// multiple filters can stack (any-of match).
const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0);
host.innerHTML = [
pill('', 'all', !active),
...inline.map(id => pill(id, workloadShort(id), id === active)),
pill('', 'all', active.size === 0),
...inline.map(id => pill(id, workloadShort(id), active.has(id))),
overflow.length ? `
<div class="oumta-tl-more">
<button class="oumta-tl-pill${overflowActive ? ' active' : ''}" data-tl-more="1">+${overflow.length} more<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
<button class="oumta-tl-pill${overflowActiveCount ? ' active' : ''}" data-tl-more="1">+${overflow.length} more${overflowActiveCount ? ` (${overflowActiveCount})` : ''}<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
<div class="oumta-tl-more-menu hidden">
${overflow.map(id => `<button class="${id === active ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
${overflow.map(id => `<button class="${active.has(id) ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
</div>
</div>` : '',
].filter(Boolean).join('');
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
btn.addEventListener('click', () => setTimelineFilter(btn.dataset.wlid || null));
btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || ''));
});
const moreBtn = host.querySelector('[data-tl-more]');
const moreMenu = host.querySelector('.oumta-tl-more-menu');
@@ -430,10 +439,12 @@ function renderTimelineFilters() {
e.stopPropagation();
moreMenu.classList.toggle('hidden');
});
// Overflow picks don't close the menu — multi-select UX relies on
// being able to tick several without the menu collapsing every time.
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
btn.addEventListener('click', () => {
setTimelineFilter(btn.dataset.wlid || null);
moreMenu.classList.add('hidden');
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleTimelineFilter(btn.dataset.wlid || '');
});
});
document.addEventListener('click', (e) => {