479 lines
12 KiB
TypeScript
479 lines
12 KiB
TypeScript
/**
|
|
* folk-gov-threshold — Numeric Progress Gate
|
|
*
|
|
* Tracks contributions toward a target value. Shows a progress bar,
|
|
* turns green when target is met. Accepts contributions via input port
|
|
* or direct UI. Target can be dynamically set via knob input port.
|
|
*/
|
|
|
|
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
import type { PortDescriptor } from "./data-types";
|
|
|
|
const HEADER_COLOR = "#0891b2";
|
|
|
|
interface Contribution {
|
|
who: string;
|
|
amount: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: var(--rs-bg-surface, #1e293b);
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
min-width: 220px;
|
|
min-height: 100px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: ${HEADER_COLOR};
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
border-radius: 10px 10px 0 0;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-actions button {
|
|
background: transparent;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.header-actions button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.title-input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.title-input::placeholder {
|
|
color: var(--rs-text-muted, #64748b);
|
|
}
|
|
|
|
.target-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
|
|
.target-input, .unit-input {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
outline: none;
|
|
}
|
|
|
|
.target-input {
|
|
width: 60px;
|
|
text-align: right;
|
|
}
|
|
|
|
.unit-input {
|
|
width: 50px;
|
|
}
|
|
|
|
.progress-wrap {
|
|
position: relative;
|
|
height: 20px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 100%;
|
|
border-radius: 10px;
|
|
transition: width 0.3s, background 0.3s;
|
|
background: ${HEADER_COLOR};
|
|
}
|
|
|
|
.progress-bar.complete {
|
|
background: #22c55e;
|
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
|
|
}
|
|
|
|
.progress-label {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: white;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.contribute-row {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.contrib-name, .contrib-amount {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 11px;
|
|
padding: 3px 6px;
|
|
outline: none;
|
|
}
|
|
|
|
.contrib-name {
|
|
flex: 1;
|
|
}
|
|
|
|
.contrib-amount {
|
|
width: 50px;
|
|
text-align: right;
|
|
}
|
|
|
|
.contrib-btn {
|
|
background: ${HEADER_COLOR};
|
|
border: none;
|
|
color: white;
|
|
border-radius: 4px;
|
|
padding: 3px 8px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.contrib-btn:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.contributions-list {
|
|
max-height: 80px;
|
|
overflow-y: auto;
|
|
font-size: 10px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
|
|
.contrib-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 2px 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.status-label {
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
text-align: center;
|
|
}
|
|
|
|
.status-label.satisfied {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.status-label.waiting {
|
|
color: #f59e0b;
|
|
}
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-gov-threshold": FolkGovThreshold;
|
|
}
|
|
}
|
|
|
|
export class FolkGovThreshold extends FolkShape {
|
|
static override tagName = "folk-gov-threshold";
|
|
|
|
static override portDescriptors: PortDescriptor[] = [
|
|
{ name: "contribution-in", type: "json", direction: "input" },
|
|
{ name: "target-in", type: "number", direction: "input" },
|
|
{ name: "gate-out", type: "json", direction: "output" },
|
|
];
|
|
|
|
static {
|
|
const sheet = new CSSStyleSheet();
|
|
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
|
|
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
|
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
|
this.styles = sheet;
|
|
}
|
|
|
|
#title = "Threshold";
|
|
#target = 100;
|
|
#unit = "$";
|
|
#contributions: Contribution[] = [];
|
|
|
|
// DOM refs
|
|
#titleEl!: HTMLInputElement;
|
|
#targetEl!: HTMLInputElement;
|
|
#unitEl!: HTMLInputElement;
|
|
#progressBar!: HTMLElement;
|
|
#progressLabel!: HTMLElement;
|
|
#contribList!: HTMLElement;
|
|
#statusEl!: HTMLElement;
|
|
#contribNameEl!: HTMLInputElement;
|
|
#contribAmountEl!: HTMLInputElement;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) {
|
|
this.#title = v;
|
|
if (this.#titleEl) this.#titleEl.value = v;
|
|
}
|
|
|
|
get target() { return this.#target; }
|
|
set target(v: number) {
|
|
this.#target = v;
|
|
if (this.#targetEl) this.#targetEl.value = String(v);
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
}
|
|
|
|
get unit() { return this.#unit; }
|
|
set unit(v: string) {
|
|
this.#unit = v;
|
|
if (this.#unitEl) this.#unitEl.value = v;
|
|
this.#updateVisuals();
|
|
}
|
|
|
|
get contributions(): Contribution[] { return [...this.#contributions]; }
|
|
set contributions(v: Contribution[]) {
|
|
this.#contributions = v;
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
}
|
|
|
|
get #current(): number {
|
|
return this.#contributions.reduce((sum, c) => sum + c.amount, 0);
|
|
}
|
|
|
|
get #percentage(): number {
|
|
return this.#target > 0 ? Math.min(100, (this.#current / this.#target) * 100) : 0;
|
|
}
|
|
|
|
get #isSatisfied(): boolean {
|
|
return this.#current >= this.#target;
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
this.initPorts();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;";
|
|
wrapper.innerHTML = html`
|
|
<div class="header" data-drag>
|
|
<span class="header-title">📊 Threshold</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Threshold title..." />
|
|
<div class="target-row">
|
|
<span>Target:</span>
|
|
<input class="target-input" type="number" min="0" />
|
|
<input class="unit-input" type="text" placeholder="unit" />
|
|
</div>
|
|
<div class="progress-wrap">
|
|
<div class="progress-bar" style="width: 0%"></div>
|
|
<div class="progress-label">0 / 100</div>
|
|
</div>
|
|
<div class="contribute-row">
|
|
<input class="contrib-name" type="text" placeholder="Name" />
|
|
<input class="contrib-amount" type="number" placeholder="0" min="0" />
|
|
<button class="contrib-btn">+</button>
|
|
</div>
|
|
<div class="contributions-list"></div>
|
|
<span class="status-label waiting">WAITING</span>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
const container = slot?.parentElement as HTMLElement;
|
|
if (container) container.replaceWith(wrapper);
|
|
|
|
// Cache refs
|
|
this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement;
|
|
this.#targetEl = wrapper.querySelector(".target-input") as HTMLInputElement;
|
|
this.#unitEl = wrapper.querySelector(".unit-input") as HTMLInputElement;
|
|
this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement;
|
|
this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement;
|
|
this.#contribList = wrapper.querySelector(".contributions-list") as HTMLElement;
|
|
this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement;
|
|
this.#contribNameEl = wrapper.querySelector(".contrib-name") as HTMLInputElement;
|
|
this.#contribAmountEl = wrapper.querySelector(".contrib-amount") as HTMLInputElement;
|
|
|
|
// Set initial values
|
|
this.#titleEl.value = this.#title;
|
|
this.#targetEl.value = String(this.#target);
|
|
this.#unitEl.value = this.#unit;
|
|
this.#updateVisuals();
|
|
|
|
// Wire events
|
|
this.#titleEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#title = this.#titleEl.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
this.#targetEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#target = parseFloat(this.#targetEl.value) || 0;
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
this.#unitEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#unit = this.#unitEl.value;
|
|
this.#updateVisuals();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
wrapper.querySelector(".contrib-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const who = this.#contribNameEl.value.trim() || "anonymous";
|
|
const amount = parseFloat(this.#contribAmountEl.value) || 0;
|
|
if (amount <= 0) return;
|
|
this.#contributions.push({ who, amount, timestamp: Date.now() });
|
|
this.#contribNameEl.value = "";
|
|
this.#contribAmountEl.value = "";
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// Prevent drag on inputs
|
|
for (const el of wrapper.querySelectorAll("input, button")) {
|
|
el.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Handle input ports
|
|
this.addEventListener("port-value-changed", ((e: CustomEvent) => {
|
|
const { name, value } = e.detail;
|
|
if (name === "contribution-in" && value && typeof value === "object") {
|
|
const c = value as any;
|
|
this.#contributions.push({
|
|
who: c.who || c.memberName || "anonymous",
|
|
amount: c.amount || c.hours || 0,
|
|
timestamp: c.timestamp || Date.now(),
|
|
});
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
if (name === "target-in" && typeof value === "number") {
|
|
this.#target = value;
|
|
if (this.#targetEl) this.#targetEl.value = String(value);
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
}) as EventListener);
|
|
|
|
return root;
|
|
}
|
|
|
|
#updateVisuals() {
|
|
if (!this.#progressBar) return;
|
|
const pct = this.#percentage;
|
|
const current = this.#current;
|
|
const satisfied = this.#isSatisfied;
|
|
|
|
this.#progressBar.style.width = `${pct}%`;
|
|
this.#progressBar.classList.toggle("complete", satisfied);
|
|
this.#progressLabel.textContent = `${current} / ${this.#target} ${this.#unit}`;
|
|
|
|
if (this.#statusEl) {
|
|
this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING";
|
|
this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`;
|
|
}
|
|
|
|
// Render contributions list
|
|
if (this.#contribList) {
|
|
this.#contribList.innerHTML = this.#contributions.map(c =>
|
|
`<div class="contrib-item"><span>${c.who}</span><span>${c.amount} ${this.#unit}</span></div>`
|
|
).join("");
|
|
}
|
|
}
|
|
|
|
#emitPort() {
|
|
this.setPortValue("gate-out", {
|
|
satisfied: this.#isSatisfied,
|
|
current: this.#current,
|
|
target: this.#target,
|
|
percentage: this.#percentage,
|
|
});
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-gov-threshold",
|
|
title: this.#title,
|
|
target: this.#target,
|
|
unit: this.#unit,
|
|
contributions: this.#contributions,
|
|
};
|
|
}
|
|
|
|
static override fromData(data: Record<string, any>): FolkGovThreshold {
|
|
const shape = FolkShape.fromData.call(this, data) as FolkGovThreshold;
|
|
if (data.title !== undefined) shape.title = data.title;
|
|
if (data.target !== undefined) shape.target = data.target;
|
|
if (data.unit !== undefined) shape.unit = data.unit;
|
|
if (data.contributions !== undefined) shape.contributions = data.contributions;
|
|
return shape;
|
|
}
|
|
|
|
override applyData(data: Record<string, any>): void {
|
|
super.applyData(data);
|
|
if (data.title !== undefined && data.title !== this.#title) this.title = data.title;
|
|
if (data.target !== undefined && data.target !== this.#target) this.target = data.target;
|
|
if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit;
|
|
if (data.contributions !== undefined) this.contributions = data.contributions;
|
|
}
|
|
}
|