feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

File diff suppressed because one or more lines are too long

174
static/js/swim-lanes.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* <swim-lanes> web component
*
* Renders colored vertical bars alongside a release timeline.
* Each bar grows from the BOTTOM of the timeline upward to the top edge
* of the last release card deployed to that environment.
* Labels are rendered at the bottom of each bar, rotated vertically.
*
* Usage:
* <swim-lanes>
* <div data-lane="staging"></div>
* <div data-lane="prod"></div>
* <div data-swimlane-timeline>
* <div data-release data-envs="staging,prod">...</div>
* <div data-release data-envs="staging">...</div>
* </div>
* </swim-lanes>
*/
const ENV_COLORS = {
prod: ["#f472b6", "#ec4899"],
production: ["#f472b6", "#ec4899"],
preprod: ["#fdba74", "#f97316"],
"pre-prod": ["#fdba74", "#f97316"],
staging: ["#fbbf24", "#ca8a04"],
stage: ["#fbbf24", "#ca8a04"],
dev: ["#a78bfa", "#7c3aed"],
development: ["#a78bfa", "#7c3aed"],
test: ["#67e8f9", "#0891b2"],
};
const DEFAULT_COLORS = ["#d1d5db", "#9ca3af"];
function envColors(name) {
const lower = name.toLowerCase();
if (ENV_COLORS[lower]) return ENV_COLORS[lower];
for (const [key, colors] of Object.entries(ENV_COLORS)) {
if (lower.includes(key)) return colors;
}
return DEFAULT_COLORS;
}
class SwimLanes extends HTMLElement {
connectedCallback() {
this.style.display = "flex";
this._render();
this._ro = new ResizeObserver(() => this._render());
const timeline = this.querySelector("[data-swimlane-timeline]");
if (timeline) {
this._ro.observe(timeline);
// Re-render when details elements are toggled (show/hide commits)
timeline.addEventListener("toggle", () => this._render(), true);
}
}
disconnectedCallback() {
if (this._ro) this._ro.disconnect();
}
_render() {
const timeline = this.querySelector("[data-swimlane-timeline]");
if (!timeline) return;
const cards = Array.from(timeline.querySelectorAll("[data-release]"));
if (cards.length === 0) return;
const timelineRect = timeline.getBoundingClientRect();
const lanes = Array.from(this.querySelectorAll("[data-lane]"));
for (const lane of lanes) {
const env = lane.dataset.lane;
const [barColor, labelColor] = envColors(env);
// Find the LAST (bottommost) card deployed to this env
let lastCard = null;
for (const card of cards) {
const envs = (card.dataset.envs || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (envs.includes(env)) lastCard = card;
}
// Bar height: from bottom of timeline up to top of the last deployed card
let barHeight = 0;
if (lastCard) {
const cardRect = lastCard.getBoundingClientRect();
barHeight = timelineRect.bottom - cardRect.top;
}
// Style the lane container
lane.style.width = "14px";
lane.style.marginRight = "4px";
lane.style.position = "relative";
lane.style.minHeight = timelineRect.height + "px";
lane.style.flexShrink = "0";
// Create or update bar (anchored to bottom)
let bar = lane.querySelector(".lane-bar");
if (!bar) {
bar = document.createElement("div");
bar.className = "lane-bar";
bar.style.position = "absolute";
bar.style.bottom = "0";
bar.style.left = "0";
bar.style.width = "100%";
bar.style.borderRadius = "9999px";
lane.appendChild(bar);
}
bar.style.height = barHeight + "px";
bar.style.backgroundColor = barColor;
// Place dots on the lane for each card deployed to this env
const existingDots = lane.querySelectorAll(".lane-dot");
let dotIndex = 0;
for (const card of cards) {
const envs = (card.dataset.envs || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!envs.includes(env)) continue;
const avatar = card.querySelector("[data-avatar]");
const anchor = avatar || card;
const anchorRect = anchor.getBoundingClientRect();
const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top;
let dot = existingDots[dotIndex];
if (!dot) {
dot = document.createElement("div");
dot.className = "lane-dot";
dot.style.position = "absolute";
dot.style.left = "50%";
dot.style.transform = "translateX(-50%)";
dot.style.width = "8px";
dot.style.height = "8px";
dot.style.borderRadius = "50%";
dot.style.backgroundColor = "#fff";
dot.style.border = "2px solid " + barColor;
dot.style.zIndex = "1";
lane.appendChild(dot);
}
dot.style.top = centerY - 4 + "px";
dot.style.borderColor = barColor;
dotIndex++;
}
// Remove extra dots from previous renders
for (let i = dotIndex; i < existingDots.length; i++) {
existingDots[i].remove();
}
// Create or update label (at the very bottom, below bars)
let label = lane.querySelector(".lane-label");
if (!label) {
label = document.createElement("span");
label.className = "lane-label";
label.style.position = "absolute";
label.style.bottom = "-4px";
label.style.left = "50%";
label.style.writingMode = "vertical-lr";
label.style.transform = "translateX(-50%) translateY(100%) rotate(180deg)";
label.style.fontSize = "10px";
label.style.fontWeight = "500";
label.style.whiteSpace = "nowrap";
label.style.paddingTop = "6px";
lane.appendChild(label);
}
label.textContent = env;
label.style.color = labelColor;
}
}
}
customElements.define("swim-lanes", SwimLanes);