Merge branch 'dev'
This commit is contained in:
commit
f4f87cf6c3
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue