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:
1 parent
9f90ea516a
commit
4aca21812e
4 files changed
+92
-37
No files matched your search
+1
-1
@@ -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,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>
|
||||
${badge}
|
||||
<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) => {
|
||||
|
||||
Reference in new issue
Block a user