Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 14:03:20 -08:00
commit f4f87cf6c3
3 changed files with 154 additions and 25 deletions

View File

@ -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:

View File

@ -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}

View File

@ -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 {
</div>
</div>
<div class="body">
<div class="conviction-chart"></div>
<div class="options-list"></div>
<div class="weight-bar"></div>
<div class="voters-count"></div>
@ -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<number>();
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
// Grid
svg += `<line x1="${PAD.left}" y1="${y(0)}" x2="${W - PAD.right}" y2="${y(0)}" stroke="#e2e8f0" stroke-width="0.5"/>`;
svg += `<line x1="${PAD.left}" y1="${y(maxV)}" x2="${W - PAD.right}" y2="${y(maxV)}" stroke="#e2e8f0" stroke-width="0.5" stroke-dasharray="3,3"/>`;
if (maxV > 2) {
const mid = maxV / 2;
svg += `<line x1="${PAD.left}" y1="${y(mid)}" x2="${W - PAD.right}" y2="${y(mid)}" stroke="#f1f5f9" stroke-width="0.5" stroke-dasharray="2,4"/>`;
}
// 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 += `<path d="${areaD}" fill="${curve.color}" opacity="0.1"/>`;
// Line
const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" ");
svg += `<path d="${lineD}" fill="none" stroke="${curve.color}" stroke-width="1.5" stroke-linejoin="round"/>`;
// End dot
const last = curve.points[curve.points.length - 1];
svg += `<circle cx="${x(last.t)}" cy="${y(last.v)}" r="2.5" fill="${curve.color}"/>`;
}
// Y axis labels
svg += `<text x="${PAD.left - 4}" y="${PAD.top + 4}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">${this.#formatConviction(maxV)}</text>`;
svg += `<text x="${PAD.left - 4}" y="${y(0)}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">0</text>`;
// 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 += `<text x="${PAD.left}" y="${H - 3}" font-size="8" fill="#94a3b8" font-family="system-ui">${fmtRange(timeRange)}</text>`;
svg += `<text x="${W - PAD.right}" y="${H - 3}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">now</text>`;
svg += "</svg>";
this.#chartEl.innerHTML = svg;
}
#renderDrawer() {
if (!this.#drawerEl) return;
const now = Date.now();