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} 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();