From 588a52f2ccdcb8470f56660f76c69cb8ba0e4d47 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 13:56:53 -0800 Subject: [PATCH 1/2] fix: rename Twenty CRM containers to twenty-ch-* prefix to avoid conflicts Existing Twenty instances on Netcup use twenty-server/twenty-db/twenty-redis names. Renamed to twenty-ch-server/twenty-ch-db/twenty-ch-redis for the commons-hub instance. Updated TWENTY_API_URL accordingly. Co-Authored-By: Claude Opus 4.6 --- deploy/twenty-crm/docker-compose.yml | 46 ++++++++++++++-------------- docker-compose.yml | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/deploy/twenty-crm/docker-compose.yml b/deploy/twenty-crm/docker-compose.yml index f4544e1..8814b46 100644 --- a/deploy/twenty-crm/docker-compose.yml +++ b/deploy/twenty-crm/docker-compose.yml @@ -10,14 +10,14 @@ # POSTGRES_PASSWORD, APP_SECRET, ADMIN_PASSWORD services: - twenty-server: + twenty-ch-server: image: twentycrm/twenty:latest - container_name: twenty-server + container_name: twenty-ch-server restart: unless-stopped depends_on: - twenty-db: + twenty-ch-db: condition: service_healthy - twenty-redis: + twenty-ch-redis: condition: service_healthy environment: # ── Core ── @@ -26,9 +26,9 @@ services: - FRONT_BASE_URL=https://crm.rspace.online - PORT=3000 # ── Database ── - - PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty + - PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-ch-db:5432/twenty # ── Redis ── - - REDIS_URL=redis://twenty-redis:6379 + - REDIS_URL=redis://twenty-ch-redis:6379 # ── Auth ── - APP_SECRET=${APP_SECRET} - ACCESS_TOKEN_SECRET=${APP_SECRET} @@ -43,7 +43,7 @@ services: - IS_BILLING_ENABLED=false - TELEMETRY_ENABLED=false volumes: - - twenty-server-data:/app/.local-storage + - twenty-ch-server-data:/app/.local-storage labels: - "traefik.enable=true" - "traefik.http.routers.twenty-crm.rule=Host(`crm.rspace.online`)" @@ -62,20 +62,20 @@ services: retries: 5 start_period: 60s - twenty-worker: + twenty-ch-worker: image: twentycrm/twenty:latest - container_name: twenty-worker + container_name: twenty-ch-worker restart: unless-stopped command: ["yarn", "worker:prod"] depends_on: - twenty-db: + twenty-ch-db: condition: service_healthy - twenty-redis: + twenty-ch-redis: condition: service_healthy environment: - NODE_ENV=production - - PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty - - REDIS_URL=redis://twenty-redis:6379 + - PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-ch-db:5432/twenty + - REDIS_URL=redis://twenty-ch-redis:6379 - APP_SECRET=${APP_SECRET} - ACCESS_TOKEN_SECRET=${APP_SECRET} - LOGIN_TOKEN_SECRET=${APP_SECRET} @@ -86,20 +86,20 @@ services: - SERVER_URL=https://crm.rspace.online - TELEMETRY_ENABLED=false volumes: - - twenty-server-data:/app/.local-storage + - twenty-ch-server-data:/app/.local-storage networks: - twenty-internal - twenty-db: + twenty-ch-db: image: postgres:16-alpine - container_name: twenty-db + container_name: twenty-ch-db restart: unless-stopped environment: - POSTGRES_DB=twenty - POSTGRES_USER=twenty - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} volumes: - - twenty-pgdata:/var/lib/postgresql/data + - twenty-ch-pgdata:/var/lib/postgresql/data networks: - twenty-internal healthcheck: @@ -109,12 +109,12 @@ services: retries: 5 start_period: 10s - twenty-redis: + twenty-ch-redis: image: redis:7-alpine - container_name: twenty-redis + container_name: twenty-ch-redis restart: unless-stopped volumes: - - twenty-redis-data:/data + - twenty-ch-redis-data:/data networks: - twenty-internal healthcheck: @@ -124,9 +124,9 @@ services: retries: 5 volumes: - twenty-server-data: - twenty-pgdata: - twenty-redis-data: + twenty-ch-server-data: + twenty-ch-pgdata: + twenty-ch-redis-data: networks: traefik-public: diff --git a/docker-compose.yml b/docker-compose.yml index a7a5ef2..14456e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: - IMAP_HOST=mail.rmail.online - IMAP_PORT=993 - IMAP_TLS_REJECT_UNAUTHORIZED=false - - TWENTY_API_URL=http://twenty-server:3000 + - TWENTY_API_URL=http://twenty-ch-server:3000 - OLLAMA_URL=http://ollama:11434 - INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID} - INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET} From 96e1afb143724c044dcd90f6e0b6048c02aadc32 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 14:03:03 -0800 Subject: [PATCH 2/2] feat: add conviction timeline chart + dynamic option reordering SVG chart plots each option's conviction growth over time with colored lines and area fills. Options now sort by conviction score (highest first) and reorder every 10s as conviction accumulates. Co-Authored-By: Claude Opus 4.6 --- lib/folk-choice-conviction.ts | 131 +++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/lib/folk-choice-conviction.ts b/lib/folk-choice-conviction.ts index fb23ac4..36d2497 100644 --- a/lib/folk-choice-conviction.ts +++ b/lib/folk-choice-conviction.ts @@ -174,6 +174,20 @@ const styles = css` text-align: center; } + .conviction-chart { + padding: 4px 8px 0; + border-bottom: 1px solid #e2e8f0; + } + + .conviction-chart svg { + width: 100%; + display: block; + } + + .conviction-chart:empty { + display: none; + } + .add-form { display: flex; gap: 6px; @@ -344,6 +358,7 @@ export class FolkChoiceConviction extends FolkShape { #weightEl: HTMLElement | null = null; #votersEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null; + #chartEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } @@ -432,6 +447,7 @@ export class FolkChoiceConviction extends FolkShape {
+
@@ -458,6 +474,7 @@ export class FolkChoiceConviction extends FolkShape { this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement; this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; + this.#chartEl = wrapper.querySelector(".conviction-chart") as HTMLElement; const titleEl = wrapper.querySelector(".title-text") as HTMLElement; const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; @@ -534,6 +551,7 @@ export class FolkChoiceConviction extends FolkShape { #render() { this.#renderOptions(); + this.#renderChart(); if (this.#drawerOpen) this.#renderDrawer(); } @@ -547,7 +565,14 @@ export class FolkChoiceConviction extends FolkShape { const maxConv = Math.max(1, ...convictions.map((c) => c.score)); const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; - this.#optionsEl.innerHTML = this.#options + // Sort options by conviction score (highest first) + const sortedOptions = [...this.#options].sort((a, b) => { + const scoreA = convictions.find((c) => c.id === a.id)!.score; + const scoreB = convictions.find((c) => c.id === b.id)!.score; + return scoreB - scoreA; + }); + + this.#optionsEl.innerHTML = sortedOptions .map((opt) => { const conv = convictions.find((c) => c.id === opt.id)!; const barWidth = (conv.score / maxConv) * 100; @@ -602,6 +627,110 @@ export class FolkChoiceConviction extends FolkShape { } } + #renderChart() { + if (!this.#chartEl) return; + if (this.#options.length === 0 || this.#stakes.length === 0) { + this.#chartEl.innerHTML = ""; + return; + } + + const now = Date.now(); + const W = 280; + const H = 100; + const PAD = { top: 10, right: 10, bottom: 18, left: 34 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + // Collect all inflection time points from stakes + const timeSet = new Set(); + for (const s of this.#stakes) timeSet.add(s.since); + timeSet.add(now); + const sortedTimes = [...timeSet].sort((a, b) => a - b); + + const earliest = sortedTimes[0]; + const timeRange = Math.max(now - earliest, 60000); // at least 1 minute + + // Compute conviction curve for each option + const curves: { id: string; color: string; points: { t: number; v: number }[] }[] = []; + let maxV = 0; + + for (const opt of this.#options) { + const optStakes = this.#stakes.filter((s) => s.optionId === opt.id); + if (optStakes.length === 0) continue; + + const points: { t: number; v: number }[] = []; + const optEarliest = Math.min(...optStakes.map((s) => s.since)); + + // Start at zero + if (optEarliest > earliest) points.push({ t: earliest, v: 0 }); + points.push({ t: optEarliest, v: 0 }); + + // Compute conviction at each time point after this option's first stake + for (const t of sortedTimes) { + if (t <= optEarliest) continue; + let score = 0; + for (const s of optStakes) { + if (s.since <= t) score += s.weight * (t - s.since) / 3600000; + } + points.push({ t, v: score }); + maxV = Math.max(maxV, score); + } + + curves.push({ id: opt.id, color: opt.color, points }); + } + + if (maxV === 0) maxV = 1; + + const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; + const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; + + let svg = ``; + + // Grid + svg += ``; + svg += ``; + if (maxV > 2) { + const mid = maxV / 2; + svg += ``; + } + + // Area fill + line + end dot for each option + for (const curve of curves) { + if (curve.points.length < 2) continue; + + // Area + const areaD = `M${x(curve.points[0].t)},${y(0)} ` + + curve.points.map((p) => `L${x(p.t)},${y(p.v)}`).join(" ") + + ` L${x(curve.points[curve.points.length - 1].t)},${y(0)} Z`; + svg += ``; + + // Line + const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); + svg += ``; + + // End dot + const last = curve.points[curve.points.length - 1]; + svg += ``; + } + + // Y axis labels + svg += `${this.#formatConviction(maxV)}`; + svg += `0`; + + // X axis labels + const fmtRange = (ms: number) => { + if (ms < 60000) return "<1m ago"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`; + return `${Math.floor(ms / 86400000)}d ago`; + }; + svg += `${fmtRange(timeRange)}`; + svg += `now`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + #renderDrawer() { if (!this.#drawerEl) return; const now = Date.now();