Merge branch 'dev'
This commit is contained in:
commit
f4f87cf6c3
|
|
@ -10,14 +10,14 @@
|
||||||
# POSTGRES_PASSWORD, APP_SECRET, ADMIN_PASSWORD
|
# POSTGRES_PASSWORD, APP_SECRET, ADMIN_PASSWORD
|
||||||
|
|
||||||
services:
|
services:
|
||||||
twenty-server:
|
twenty-ch-server:
|
||||||
image: twentycrm/twenty:latest
|
image: twentycrm/twenty:latest
|
||||||
container_name: twenty-server
|
container_name: twenty-ch-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
twenty-db:
|
twenty-ch-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
twenty-redis:
|
twenty-ch-redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# ── Core ──
|
# ── Core ──
|
||||||
|
|
@ -26,9 +26,9 @@ services:
|
||||||
- FRONT_BASE_URL=https://crm.rspace.online
|
- FRONT_BASE_URL=https://crm.rspace.online
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
# ── Database ──
|
# ── 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 ──
|
||||||
- REDIS_URL=redis://twenty-redis:6379
|
- REDIS_URL=redis://twenty-ch-redis:6379
|
||||||
# ── Auth ──
|
# ── Auth ──
|
||||||
- APP_SECRET=${APP_SECRET}
|
- APP_SECRET=${APP_SECRET}
|
||||||
- ACCESS_TOKEN_SECRET=${APP_SECRET}
|
- ACCESS_TOKEN_SECRET=${APP_SECRET}
|
||||||
|
|
@ -43,7 +43,7 @@ services:
|
||||||
- IS_BILLING_ENABLED=false
|
- IS_BILLING_ENABLED=false
|
||||||
- TELEMETRY_ENABLED=false
|
- TELEMETRY_ENABLED=false
|
||||||
volumes:
|
volumes:
|
||||||
- twenty-server-data:/app/.local-storage
|
- twenty-ch-server-data:/app/.local-storage
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.twenty-crm.rule=Host(`crm.rspace.online`)"
|
- "traefik.http.routers.twenty-crm.rule=Host(`crm.rspace.online`)"
|
||||||
|
|
@ -62,20 +62,20 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
twenty-worker:
|
twenty-ch-worker:
|
||||||
image: twentycrm/twenty:latest
|
image: twentycrm/twenty:latest
|
||||||
container_name: twenty-worker
|
container_name: twenty-ch-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["yarn", "worker:prod"]
|
command: ["yarn", "worker:prod"]
|
||||||
depends_on:
|
depends_on:
|
||||||
twenty-db:
|
twenty-ch-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
twenty-redis:
|
twenty-ch-redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty
|
- PG_DATABASE_URL=postgres://twenty:${POSTGRES_PASSWORD}@twenty-ch-db:5432/twenty
|
||||||
- REDIS_URL=redis://twenty-redis:6379
|
- REDIS_URL=redis://twenty-ch-redis:6379
|
||||||
- APP_SECRET=${APP_SECRET}
|
- APP_SECRET=${APP_SECRET}
|
||||||
- ACCESS_TOKEN_SECRET=${APP_SECRET}
|
- ACCESS_TOKEN_SECRET=${APP_SECRET}
|
||||||
- LOGIN_TOKEN_SECRET=${APP_SECRET}
|
- LOGIN_TOKEN_SECRET=${APP_SECRET}
|
||||||
|
|
@ -86,20 +86,20 @@ services:
|
||||||
- SERVER_URL=https://crm.rspace.online
|
- SERVER_URL=https://crm.rspace.online
|
||||||
- TELEMETRY_ENABLED=false
|
- TELEMETRY_ENABLED=false
|
||||||
volumes:
|
volumes:
|
||||||
- twenty-server-data:/app/.local-storage
|
- twenty-ch-server-data:/app/.local-storage
|
||||||
networks:
|
networks:
|
||||||
- twenty-internal
|
- twenty-internal
|
||||||
|
|
||||||
twenty-db:
|
twenty-ch-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: twenty-db
|
container_name: twenty-ch-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=twenty
|
- POSTGRES_DB=twenty
|
||||||
- POSTGRES_USER=twenty
|
- POSTGRES_USER=twenty
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- twenty-pgdata:/var/lib/postgresql/data
|
- twenty-ch-pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- twenty-internal
|
- twenty-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -109,12 +109,12 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
twenty-redis:
|
twenty-ch-redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: twenty-redis
|
container_name: twenty-ch-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- twenty-redis-data:/data
|
- twenty-ch-redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- twenty-internal
|
- twenty-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -124,9 +124,9 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
twenty-server-data:
|
twenty-ch-server-data:
|
||||||
twenty-pgdata:
|
twenty-ch-pgdata:
|
||||||
twenty-redis-data:
|
twenty-ch-redis-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ services:
|
||||||
- IMAP_HOST=mail.rmail.online
|
- IMAP_HOST=mail.rmail.online
|
||||||
- IMAP_PORT=993
|
- IMAP_PORT=993
|
||||||
- IMAP_TLS_REJECT_UNAUTHORIZED=false
|
- 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
|
- OLLAMA_URL=http://ollama:11434
|
||||||
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
|
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
|
||||||
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}
|
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,20 @@ const styles = css`
|
||||||
text-align: center;
|
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 {
|
.add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -344,6 +358,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
#weightEl: HTMLElement | null = null;
|
#weightEl: HTMLElement | null = null;
|
||||||
#votersEl: HTMLElement | null = null;
|
#votersEl: HTMLElement | null = null;
|
||||||
#drawerEl: HTMLElement | null = null;
|
#drawerEl: HTMLElement | null = null;
|
||||||
|
#chartEl: HTMLElement | null = null;
|
||||||
|
|
||||||
get title() { return this.#title; }
|
get title() { return this.#title; }
|
||||||
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
||||||
|
|
@ -432,6 +447,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
<div class="conviction-chart"></div>
|
||||||
<div class="options-list"></div>
|
<div class="options-list"></div>
|
||||||
<div class="weight-bar"></div>
|
<div class="weight-bar"></div>
|
||||||
<div class="voters-count"></div>
|
<div class="voters-count"></div>
|
||||||
|
|
@ -458,6 +474,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
||||||
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
||||||
this.#drawerEl = wrapper.querySelector(".results-drawer") 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 titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
||||||
|
|
||||||
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
||||||
|
|
@ -534,6 +551,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
|
|
||||||
#render() {
|
#render() {
|
||||||
this.#renderOptions();
|
this.#renderOptions();
|
||||||
|
this.#renderChart();
|
||||||
if (this.#drawerOpen) this.#renderDrawer();
|
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 maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
||||||
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
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) => {
|
.map((opt) => {
|
||||||
const conv = convictions.find((c) => c.id === opt.id)!;
|
const conv = convictions.find((c) => c.id === opt.id)!;
|
||||||
const barWidth = (conv.score / maxConv) * 100;
|
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() {
|
#renderDrawer() {
|
||||||
if (!this.#drawerEl) return;
|
if (!this.#drawerEl) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue