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
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
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).
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
// Off by default; enable via env for local troubleshooting only.
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "2.0.7",
|
"version": "2.0.8",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -254,24 +254,57 @@
|
|||||||
|
|
||||||
/* Timeline body: two-column layout (left = date badge + time + dot, right
|
/* Timeline body: two-column layout (left = date badge + time + dot, right
|
||||||
* = event list). Clusters of events that fired within the same tolerance
|
* = 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 {
|
.oumta-timeline-body {
|
||||||
display: flex; flex-direction: column;
|
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 {
|
.oumta-timeline-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px 1fr;
|
grid-template-columns: 80px 1fr;
|
||||||
gap: 14px;
|
grid-template-rows: auto auto; /* optional date row on top, dot-row + events below */
|
||||||
padding: 10px 0 14px;
|
gap: 0 14px;
|
||||||
position: relative;
|
position: relative;
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
.oumta-timeline-entry + .oumta-timeline-entry { border-top: 1px solid var(--t-border); }
|
.oumta-timeline-entry > * { position: relative; z-index: 1; }
|
||||||
.oumta-tl-left {
|
/* Time + dot share a horizontal row so clusters without a date badge
|
||||||
display: flex; flex-direction: column;
|
* 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;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
min-height: 40px;
|
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 {
|
.oumta-tl-date {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
@@ -281,7 +314,11 @@
|
|||||||
border-radius: var(--ui-radius-card);
|
border-radius: var(--ui-radius-card);
|
||||||
padding: 4px 0 5px;
|
padding: 4px 0 5px;
|
||||||
width: 44px;
|
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 {
|
.oumta-tl-date .day {
|
||||||
font-family: var(--font-content, monospace);
|
font-family: var(--font-content, monospace);
|
||||||
@@ -306,7 +343,12 @@
|
|||||||
.oumta-tl-dot {
|
.oumta-tl-dot {
|
||||||
width: 10px; height: 10px;
|
width: 10px; height: 10px;
|
||||||
border-radius: 50%;
|
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);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
|
||||||
background: var(--oumta-dot-color);
|
background: var(--oumta-dot-color);
|
||||||
}
|
}
|
||||||
@@ -318,8 +360,10 @@
|
|||||||
|
|
||||||
.oumta-tl-right {
|
.oumta-tl-right {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
gap: 8px;
|
gap: 23px;
|
||||||
min-width: 0;
|
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 {
|
.oumta-tl-event {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const state = {
|
|||||||
pollHandle: null,
|
pollHandle: null,
|
||||||
sseHandle: null,
|
sseHandle: null,
|
||||||
data: { overview: null, workloads: [], timeline: [], scopeWorkload: 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 ─────────────────────────────────────────
|
// ── Central API surface ─────────────────────────────────────────
|
||||||
@@ -314,14 +314,15 @@ function renderTimeline() {
|
|||||||
scopeEl.textContent = 'events for ' + scope;
|
scopeEl.textContent = 'events for ' + scope;
|
||||||
backEl.classList.remove('hidden');
|
backEl.classList.remove('hidden');
|
||||||
} else {
|
} 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');
|
backEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
const filter = state.timelineFilter;
|
const filter = state.timelineFilter;
|
||||||
const events = state.data.timeline.filter(e => {
|
const events = state.data.timeline.filter(e => {
|
||||||
if (scope) return e.workloadId === scope;
|
if (scope) return e.workloadId === scope;
|
||||||
if (filter) return e.workloadId === filter;
|
if (filter.size === 0) return true;
|
||||||
return true;
|
return filter.has(e.workloadId);
|
||||||
});
|
});
|
||||||
renderTimelineFilters();
|
renderTimelineFilters();
|
||||||
|
|
||||||
@@ -345,10 +346,10 @@ function renderTimeline() {
|
|||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
const left = `
|
const left = `
|
||||||
<div class="oumta-tl-left">
|
${badge}
|
||||||
${badge}
|
<div class="oumta-tl-row">
|
||||||
<div class="oumta-tl-dot" data-sev="${sev}"></div>
|
<span class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</span>
|
||||||
<div class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</div>
|
<span class="oumta-tl-dot" data-sev="${sev}"></span>
|
||||||
</div>`;
|
</div>`;
|
||||||
const right = `
|
const right = `
|
||||||
<div class="oumta-tl-right">
|
<div class="oumta-tl-right">
|
||||||
@@ -370,14 +371,19 @@ function renderTimeline() {
|
|||||||
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
|
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
|
||||||
}).join('');
|
}).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 => {
|
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
|
||||||
el.addEventListener('click', () => setTimelineFilter(el.dataset.wlid));
|
el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTimelineFilter(workloadId) {
|
/** Toggle a workload in the multi-select filter set. Passing an empty
|
||||||
state.timelineFilter = workloadId || null;
|
* 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();
|
renderTimeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,26 +408,29 @@ function renderTimelineFilters() {
|
|||||||
const INLINE_LIMIT = 6;
|
const INLINE_LIMIT = 6;
|
||||||
const inline = workloads.slice(0, INLINE_LIMIT);
|
const inline = workloads.slice(0, INLINE_LIMIT);
|
||||||
const overflow = workloads.slice(INLINE_LIMIT);
|
const overflow = workloads.slice(INLINE_LIMIT);
|
||||||
const active = state.timelineFilter;
|
const active = state.timelineFilter; // Set<workloadId>
|
||||||
|
|
||||||
const pill = (id, label, isActive) =>
|
const pill = (id, label, isActive) =>
|
||||||
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
|
`<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 = [
|
host.innerHTML = [
|
||||||
pill('', 'all', !active),
|
pill('', 'all', active.size === 0),
|
||||||
...inline.map(id => pill(id, workloadShort(id), id === active)),
|
...inline.map(id => pill(id, workloadShort(id), active.has(id))),
|
||||||
overflow.length ? `
|
overflow.length ? `
|
||||||
<div class="oumta-tl-more">
|
<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">
|
<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>
|
||||||
</div>` : '',
|
</div>` : '',
|
||||||
].filter(Boolean).join('');
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
|
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 moreBtn = host.querySelector('[data-tl-more]');
|
||||||
const moreMenu = host.querySelector('.oumta-tl-more-menu');
|
const moreMenu = host.querySelector('.oumta-tl-more-menu');
|
||||||
@@ -430,10 +439,12 @@ function renderTimelineFilters() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
moreMenu.classList.toggle('hidden');
|
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 => {
|
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', (e) => {
|
||||||
setTimelineFilter(btn.dataset.wlid || null);
|
e.stopPropagation();
|
||||||
moreMenu.classList.add('hidden');
|
toggleTimelineFilter(btn.dataset.wlid || '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
|
|||||||
Reference in new issue
Block a user