diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts
index 4181df8a954..3a1a117d02d 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -38,6 +38,14 @@ export const DASHBOARD_USER_DATASET_CREATE = `${DASHBOARD_USER_DATASET}/create`;
export const DASHBOARD_USER_COMPUTING_UNIT = `${DASHBOARD_USER}/compute`;
export const DASHBOARD_USER_QUOTA = `${DASHBOARD_USER}/quota`;
export const DASHBOARD_USER_DISCUSSION = `${DASHBOARD_USER}/discussion`;
+export const DASHBOARD_USER_BET_PILOT = `${DASHBOARD_USER}/bet-pilot`;
+export const DASHBOARD_USER_BET_PILOT_TODAY = `${DASHBOARD_USER_BET_PILOT}/today`;
+export const DASHBOARD_USER_BET_PILOT_SCOUTING = `${DASHBOARD_USER_BET_PILOT}/scouting`;
+export const DASHBOARD_USER_BET_PILOT_HEALTH = `${DASHBOARD_USER_BET_PILOT}/health`;
+export const DASHBOARD_USER_BET_PILOT_BANKROLL = `${DASHBOARD_USER_BET_PILOT}/bankroll`;
+export const DASHBOARD_USER_BET_PILOT_CALIBRATION = `${DASHBOARD_USER_BET_PILOT}/calibration`;
+export const DASHBOARD_USER_BET_PILOT_GLOSSARY = `${DASHBOARD_USER_BET_PILOT}/glossary`;
+export const DASHBOARD_USER_BET_PILOT_LINES_INPUT = `${DASHBOARD_USER_BET_PILOT}/lines-input`;
export const DASHBOARD_ADMIN = `${DASHBOARD}/admin`;
export const DASHBOARD_ADMIN_USER = `${DASHBOARD_ADMIN}/user`;
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 179caf5c088..aa9f8f43349 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -42,6 +42,14 @@ import { DASHBOARD_ABOUT, DASHBOARD_USER_WORKFLOW } from "./app-routing.constant
import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component";
import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component";
import { GuiConfigService } from "./common/service/gui-config.service";
+import { BetPilotComponent } from "./dashboard/component/user/bet-pilot/bet-pilot.component";
+import { BpTodayComponent } from "./dashboard/component/user/bet-pilot/screens/bp-today.component";
+import { BpScoutingComponent } from "./dashboard/component/user/bet-pilot/screens/bp-scouting.component";
+import { BpHealthComponent } from "./dashboard/component/user/bet-pilot/screens/bp-health.component";
+import { BpBankrollComponent } from "./dashboard/component/user/bet-pilot/screens/bp-bankroll.component";
+import { BpCalibrationComponent } from "./dashboard/component/user/bet-pilot/screens/bp-calibration.component";
+import { BpGlossaryComponent } from "./dashboard/component/user/bet-pilot/screens/bp-glossary.component";
+import { BpLinesInputComponent } from "./dashboard/component/user/bet-pilot/screens/bp-lines-input.component";
const rootRedirectGuard: CanActivateFn = () => {
const config = inject(GuiConfigService);
@@ -143,6 +151,20 @@ routes.push({
path: "discussion",
component: FlarumComponent,
},
+ {
+ path: "bet-pilot",
+ component: BetPilotComponent,
+ children: [
+ { path: "", redirectTo: "today", pathMatch: "full" },
+ { path: "today", component: BpTodayComponent },
+ { path: "scouting", component: BpScoutingComponent },
+ { path: "health", component: BpHealthComponent },
+ { path: "bankroll", component: BpBankrollComponent },
+ { path: "calibration", component: BpCalibrationComponent },
+ { path: "glossary", component: BpGlossaryComponent },
+ { path: "lines-input", component: BpLinesInputComponent },
+ ],
+ },
],
},
{
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 21928b77039..d6709e7c75a 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -58,6 +58,15 @@ import { UserWorkflowComponent } from "./dashboard/component/user/user-workflow/
import { ShareAccessComponent } from "./dashboard/component/user/share-access/share-access.component";
import { WorkflowExecutionHistoryComponent } from "./dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component";
import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-quota.component";
+import { BetPilotComponent } from "./dashboard/component/user/bet-pilot/bet-pilot.component";
+import { BpTodayComponent } from "./dashboard/component/user/bet-pilot/screens/bp-today.component";
+import { BpScoutingComponent } from "./dashboard/component/user/bet-pilot/screens/bp-scouting.component";
+import { BpHealthComponent } from "./dashboard/component/user/bet-pilot/screens/bp-health.component";
+import { BpBankrollComponent } from "./dashboard/component/user/bet-pilot/screens/bp-bankroll.component";
+import { BpCalibrationComponent } from "./dashboard/component/user/bet-pilot/screens/bp-calibration.component";
+import { BpGlossaryComponent } from "./dashboard/component/user/bet-pilot/screens/bp-glossary.component";
+import { BpWfPreviewComponent } from "./dashboard/component/user/bet-pilot/screens/bp-wf-preview.component";
+import { BpLinesInputComponent } from "./dashboard/component/user/bet-pilot/screens/bp-lines-input.component";
import { UserIconComponent } from "./dashboard/component/user/user-icon/user-icon.component";
import { UserAvatarComponent } from "./dashboard/component/user/user-avatar/user-avatar.component";
import { CodeEditorComponent } from "./workspace/component/code-editor-dialog/code-editor.component";
@@ -286,6 +295,15 @@ registerLocaleData(en);
LocalLoginComponent,
UserWorkflowComponent,
UserQuotaComponent,
+ BetPilotComponent,
+ BpTodayComponent,
+ BpScoutingComponent,
+ BpHealthComponent,
+ BpBankrollComponent,
+ BpCalibrationComponent,
+ BpGlossaryComponent,
+ BpWfPreviewComponent,
+ BpLinesInputComponent,
RowModalComponent,
OperatorLabelComponent,
MiniMapComponent,
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html
index b04eafb3107..8a3e017d6a5 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -189,6 +189,27 @@
+
+
+
+
+ Bet Pilot
+ {{ BET_PILOT_VERSION }}
+
+
+
+
+
+ {{ appTheme === "dark" ? "☀" : "☾" }}
+
diff --git a/frontend/src/app/dashboard/component/dashboard.component.scss b/frontend/src/app/dashboard/component/dashboard.component.scss
index e327718ca13..aeb74035d14 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.scss
+++ b/frontend/src/app/dashboard/component/dashboard.component.scss
@@ -119,3 +119,45 @@ nz-content {
max-height: 100%;
overflow: hidden;
}
+
+.app-version-pill {
+ margin-left: auto;
+ padding: 1px 7px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ background: rgba(255, 70, 85, 0.12);
+ color: #ff4655;
+ border: 1px solid rgba(255, 70, 85, 0.45);
+ border-radius: 999px;
+ line-height: 1.5;
+}
+
+.app-theme-toggle {
+ background: transparent;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ color: inherit;
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 15px;
+ display: inline-grid;
+ place-items: center;
+ margin: 0 8px;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease;
+ &:hover {
+ background: rgba(0, 0, 0, 0.04);
+ border-color: rgba(0, 0, 0, 0.22);
+ }
+}
+html[data-app-theme="dark"] .app-theme-toggle {
+ border-color: rgba(255, 255, 255, 0.12);
+ &:hover {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.22);
+ }
+}
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts
index 57e6e8e284e..2f979fd5bd0 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -40,6 +40,7 @@ import {
DASHBOARD_USER_PROJECT,
DASHBOARD_USER_QUOTA,
DASHBOARD_USER_WORKFLOW,
+ DASHBOARD_USER_BET_PILOT,
} from "../../app-routing.constant";
import { Version } from "../../../environments/version";
import { SidebarTabs } from "../../common/type/gui-config";
@@ -115,6 +116,32 @@ export class DashboardComponent implements OnInit {
protected readonly DASHBOARD_ADMIN_GMAIL = DASHBOARD_ADMIN_GMAIL;
protected readonly DASHBOARD_ADMIN_EXECUTION = DASHBOARD_ADMIN_EXECUTION;
protected readonly DASHBOARD_ADMIN_SETTINGS = DASHBOARD_ADMIN_SETTINGS;
+ protected readonly DASHBOARD_USER_BET_PILOT = DASHBOARD_USER_BET_PILOT;
+ protected readonly BET_PILOT_VERSION = "v0.1.0";
+
+ // Global theme — applies across all of Texera and Bet Pilot.
+ appTheme: "dark" | "light" = "dark";
+
+ toggleAppTheme(): void {
+ this.applyAppTheme(this.appTheme === "dark" ? "light" : "dark");
+ }
+
+ private applyAppTheme(t: "dark" | "light"): void {
+ this.appTheme = t;
+ document.documentElement.setAttribute("data-bp-theme", t);
+ document.documentElement.setAttribute("data-app-theme", t);
+ try {
+ globalThis.localStorage?.setItem("betpilot-theme", t);
+ } catch {}
+ }
+
+ private readAppTheme(): "dark" | "light" {
+ try {
+ return (globalThis.localStorage?.getItem("betpilot-theme") as "dark" | "light" | null) || "dark";
+ } catch {
+ return "dark";
+ }
+ }
constructor(
private userService: UserService,
@@ -130,6 +157,9 @@ export class DashboardComponent implements OnInit {
ngOnInit(): void {
this.isCollapsed = false;
+ // Restore saved theme on app boot.
+ this.applyAppTheme(this.readAppTheme());
+
this.router.events.pipe(untilDestroyed(this)).subscribe(() => {
this.checkRoute();
});
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.html
new file mode 100644
index 00000000000..9b9354e4446
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.scss b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.scss
new file mode 100644
index 00000000000..66379a4f2d8
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.scss
@@ -0,0 +1,231 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Bet Pilot shell — owns the theme tokens so all children inherit them.
+
+:host {
+ display: block;
+
+ --bp-bg: #0b0d12;
+ --bp-bg-elev: #0f1218;
+ --bp-panel: #14171f;
+ --bp-panel-2: #1a1e28;
+ --bp-line: #262b39;
+ --bp-text: #ecf0f8;
+ --bp-text-2: #cdd3e0;
+ --bp-muted: #8b93a7;
+ --bp-accent: #ff4655;
+ --bp-accent-soft: rgba(255, 70, 85, 0.16);
+ --bp-accent-tint: #1a151a;
+ --bp-good: #4ade80;
+ --bp-good-soft: rgba(74, 222, 128, 0.14);
+ --bp-warn: #facc15;
+ --bp-warn-soft: rgba(250, 204, 21, 0.14);
+ --bp-bad: #f87171;
+ --bp-popover: #050608;
+ --bp-link: #7aa7ff;
+ --bp-shadow: 0 1px 0 #ffffff05, 0 24px 70px -32px #00000099;
+}
+
+:host-context(html[data-bp-theme="light"]) {
+ --bp-bg: #f7f7fb;
+ --bp-bg-elev: #ffffff;
+ --bp-panel: #ffffff;
+ --bp-panel-2: #f3f4f8;
+ --bp-line: #e3e6ee;
+ --bp-text: #0d1320;
+ --bp-text-2: #2c3344;
+ --bp-muted: #6b7180;
+ --bp-accent: #ff3b48;
+ --bp-accent-soft: rgba(255, 59, 72, 0.1);
+ --bp-accent-tint: #fff5f6;
+ --bp-good: #16a34a;
+ --bp-good-soft: rgba(22, 163, 74, 0.1);
+ --bp-warn: #b45309;
+ --bp-warn-soft: rgba(180, 83, 9, 0.1);
+ --bp-bad: #dc2626;
+ --bp-popover: #ffffff;
+ --bp-link: #2563eb;
+ --bp-shadow: 0 2px 0 rgba(13, 19, 32, 0.02), 0 24px 60px -28px rgba(13, 19, 32, 0.28);
+}
+
+.bp-shell {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ min-height: 100%;
+ background: var(--bp-bg);
+ color: var(--bp-text);
+ font-family:
+ "Inter",
+ -apple-system,
+ system-ui,
+ sans-serif;
+}
+
+.bp-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ order: 1; /* explicitly on the LEFT */
+ padding: 0;
+}
+
+.bp-sidebar {
+ flex: 0 0 260px;
+ order: 2; /* explicitly on the RIGHT */
+ background: var(--bp-bg-elev);
+ border-left: 1px solid var(--bp-line);
+ padding: 28px 18px 18px;
+ display: flex;
+ flex-direction: column;
+}
+
+.bp-sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 4px 4px 0;
+ margin-bottom: 28px;
+ .brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ .dot {
+ width: 10px;
+ height: 10px;
+ background: var(--bp-accent);
+ border-radius: 3px;
+ box-shadow: 0 0 0 3px var(--bp-accent-soft);
+ }
+ .wordmark {
+ font-weight: 700;
+ letter-spacing: -0.015em;
+ font-size: 15px;
+ }
+ .badge-version {
+ padding: 1px 7px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ background: var(--bp-accent-soft);
+ color: var(--bp-accent);
+ border: 1px solid var(--bp-accent);
+ border-radius: 999px;
+ line-height: 1.5;
+ }
+ }
+}
+
+.icon-btn {
+ background: var(--bp-panel-2);
+ border: 1px solid var(--bp-line);
+ color: var(--bp-muted);
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ display: grid;
+ place-items: center;
+ transition:
+ color 0.15s,
+ border-color 0.15s;
+ &:hover {
+ color: var(--bp-text);
+ border-color: var(--bp-text);
+ }
+}
+
+nav {
+ flex: 1;
+ overflow-y: auto;
+}
+.nav-section {
+ margin-bottom: 22px;
+}
+
+.nav-section-title {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--bp-muted);
+ padding: 0 12px 8px;
+ opacity: 0.7;
+ font-weight: 600;
+ text-align: right;
+}
+
+.nav-item {
+ display: grid;
+ grid-template-columns: 1fr auto auto; /* label | sub-badge | icon */
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: var(--bp-muted);
+ text-decoration: none;
+ font-size: 13.5px;
+ cursor: pointer;
+ margin-bottom: 2px;
+ border: 0;
+ background: transparent;
+ width: 100%;
+ text-align: right;
+ transition:
+ background 0.12s,
+ color 0.12s;
+ font-family: inherit;
+
+ > span:first-child {
+ font-weight: 500;
+ }
+
+ &:hover {
+ background: var(--bp-panel-2);
+ color: var(--bp-text);
+ }
+ &.active {
+ background: var(--bp-accent-tint, var(--bp-panel-2));
+ color: var(--bp-text);
+ border-right: 2px solid var(--bp-accent);
+ padding-right: 10px;
+ }
+ .nav-icon {
+ width: 18px;
+ text-align: center;
+ opacity: 0.85;
+ }
+ .nav-sub {
+ font-size: 11px;
+ color: var(--bp-muted);
+ padding: 1px 7px;
+ background: var(--bp-panel-2);
+ border-radius: 999px;
+ line-height: 1.5;
+ }
+}
+
+.bp-sidebar-bottom {
+ padding-top: 14px;
+ border-top: 1px solid var(--bp-line);
+}
+
+.bp-main {
+ overflow-x: hidden;
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.ts
new file mode 100644
index 00000000000..4d4816ae17d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.component.ts
@@ -0,0 +1,48 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { RouterModule } from "@angular/router";
+import {
+ DASHBOARD_USER_BET_PILOT_TODAY,
+ DASHBOARD_USER_BET_PILOT_SCOUTING,
+ DASHBOARD_USER_BET_PILOT_HEALTH,
+ DASHBOARD_USER_BET_PILOT_BANKROLL,
+ DASHBOARD_USER_BET_PILOT_CALIBRATION,
+ DASHBOARD_USER_BET_PILOT_GLOSSARY,
+ DASHBOARD_USER_BET_PILOT_LINES_INPUT,
+} from "../../../../app-routing.constant";
+
+@Component({
+ selector: "texera-bet-pilot",
+ standalone: true,
+ imports: [CommonModule, RouterModule],
+ templateUrl: "./bet-pilot.component.html",
+ styleUrls: ["./bet-pilot.component.scss"],
+})
+export class BetPilotComponent {
+ protected readonly LINK_TODAY = DASHBOARD_USER_BET_PILOT_TODAY;
+ protected readonly LINK_SCOUTING = DASHBOARD_USER_BET_PILOT_SCOUTING;
+ protected readonly LINK_HEALTH = DASHBOARD_USER_BET_PILOT_HEALTH;
+ protected readonly LINK_BANKROLL = DASHBOARD_USER_BET_PILOT_BANKROLL;
+ protected readonly LINK_CALIBRATION = DASHBOARD_USER_BET_PILOT_CALIBRATION;
+ protected readonly LINK_GLOSSARY = DASHBOARD_USER_BET_PILOT_GLOSSARY;
+ protected readonly LINK_LINES_INPUT = DASHBOARD_USER_BET_PILOT_LINES_INPUT;
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.spec.ts b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.spec.ts
new file mode 100644
index 00000000000..89d5b8bf7b4
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.spec.ts
@@ -0,0 +1,56 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BetPilotService, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "./bet-pilot.service";
+
+describe("BetPilotService", () => {
+ it("should expose workflow links for imported workflow ids", () => {
+ expect(texeraWorkflowUrl("wf2_daily")).toBe(`/dashboard/user/workflow/${TEXERA_WORKFLOW_IDS.wf2_daily}`);
+ });
+
+ it("should return null for unknown workflow keys at runtime", () => {
+ expect(texeraWorkflowUrl("missing" as keyof typeof TEXERA_WORKFLOW_IDS)).toBeNull();
+ });
+
+ it("should return consistent daily pick summary data", () => {
+ const dailyPicks = new BetPilotService().getDailyPicks();
+
+ expect(dailyPicks.bankroll).toBeGreaterThan(570);
+ expect(dailyPicks.stakePerParlay).toBeGreaterThan(0);
+ expect(dailyPicks.slips).toHaveLength(2);
+ expect(dailyPicks.picks.map(pick => `${pick.player} ${pick.line}`)).toContain("PatMen 31.5");
+ expect(dailyPicks.considered.length).toBeGreaterThan(0);
+ expect(dailyPicks.considered.every(player => player.reasonShort.length > 0)).toBe(true);
+ });
+
+ it("should link scouting rows to their source match pages", () => {
+ const report = new BetPilotService().getScoutingReport();
+
+ expect(report.recent).toHaveLength(8);
+ expect(report.recent.every(match => match.matchUrl.startsWith("https://www.vlr.gg/"))).toBe(true);
+ });
+
+ it("should show a settled bankroll curve above the starting balance", () => {
+ const bankroll = new BetPilotService().getBankroll();
+
+ expect(bankroll.totalBalance).toBeGreaterThan(570);
+ expect(bankroll.series[bankroll.series.length - 1].value).toBe(bankroll.totalBalance);
+ expect(bankroll.changeAbs).toBeCloseTo(bankroll.totalBalance - bankroll.startingBalance, 2);
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.ts b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.ts
new file mode 100644
index 00000000000..ddd619af416
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/bet-pilot.service.ts
@@ -0,0 +1,594 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable } from "@angular/core";
+
+// =========================================================================
+// Stand-in data contract. Replace each get*() with a real workflow call
+// (via HttpClient) once the model is wired up.
+// =========================================================================
+
+export interface ConsideredPlayer {
+ status: "SKIPPED" | "NEEDS_REVIEW";
+ player: string;
+ line: number;
+ reasonShort: string;
+ reasonDetail: string;
+}
+
+export interface PickCard {
+ player: string;
+ line: number;
+ direction: "OVER" | "UNDER";
+ finalProjection: number;
+ projectionStdDev: number;
+ gap: number;
+ edgeScore: number;
+ confidence: number;
+ matchLabel: string;
+ startTime: string;
+ actualKills?: number;
+ llmReasoning: string;
+}
+
+export interface SlipSummary {
+ title: string;
+ multiplier: number;
+ originalMultiplier?: number;
+ stake: number;
+ payout: number;
+ promoLabel: string;
+ time: string;
+ location: string;
+ picks: PickCard[];
+}
+
+export interface DailyPicks {
+ date: string;
+ matchLabel: string;
+ bankroll: number;
+ stakePerParlay: number;
+ slips: SlipSummary[];
+ picks: PickCard[];
+ considered: ConsideredPlayer[];
+}
+
+export interface RecentMatch {
+ date: string;
+ matchUrl: string;
+ matchLabel: string;
+ map: string;
+ agent: string;
+ kills: number;
+ notes: string;
+}
+
+export interface MapProb {
+ map: string;
+ pct: number;
+}
+
+export interface ScouteScenario {
+ scenario: string;
+ map: string;
+ prob: number;
+ expectedKills: number;
+ notes: string;
+}
+
+export interface ScoutingReport {
+ player: string;
+ team: string;
+ opponent: string;
+ date: string;
+ line: number;
+ direction: "OVER" | "UNDER";
+ finalProjection: number;
+ projectionStdDev: number;
+ gap: number;
+ edgeScore: number;
+ confidence: number;
+ recent: RecentMatch[];
+ onMapSummary: { map: string; avg: number; std: number; n: number }[];
+ slot1Probs: MapProb[];
+ slot2Probs: MapProb[];
+ scenarios: ScouteScenario[];
+ ruleProjected: number;
+ neuralResidual: number;
+ residualReasons: string[];
+ llmReasoning: string;
+ kellyFraction: number;
+}
+
+export interface CalibrationRow {
+ bucket: string;
+ predicted: number;
+ actual: number;
+ verdict: "too optimistic" | "slightly optimistic" | "slightly cautious" | "accurate";
+ verdictPos: boolean;
+}
+
+export interface EdgeSliceRow {
+ slice: string;
+ n: number;
+ clvPp: number;
+}
+
+export interface ModelHealth {
+ gatePct: number;
+ picksEvaluated: number;
+ picksRequired: number;
+ rollingClvSeries: { x: number; y: number }[];
+ calibrationRows: CalibrationRow[];
+ edgeSlices: EdgeSliceRow[];
+ takeaway: string;
+}
+
+export interface BankrollPoint {
+ x: number;
+ y: number;
+ date: string;
+ value: number;
+}
+
+export interface Bankroll {
+ totalBalance: number;
+ startingBalance: number;
+ changeAbs: number;
+ changePct: number;
+ rangeLabel: string;
+ series: BankrollPoint[];
+ settledBets: number;
+ wonBets: number;
+ lostBets: number;
+ hitRate: number;
+ modelClaimedHitRate: number;
+}
+
+export interface CalibrationVersion {
+ version: string;
+ refitDate: string;
+ active: boolean;
+ changed: string;
+ brierScore: number | null;
+ trend: "baseline" | "better" | "worse" | "n/a";
+}
+
+export interface CalibrationLog {
+ versions: CalibrationVersion[];
+ nextRefit: string;
+ resolvedBetsAvailable: number;
+ minBetsRequired: number;
+ brierTrendDelta: number;
+}
+
+// =========================================================================
+// Texera workflow ID mapping. Update the right-hand numbers as each
+// workflow JSON gets imported (Your Work → Workflows → Import).
+// WF-2 (Daily predict + bet pick) was the first verified import.
+// =========================================================================
+export const TEXERA_WORKFLOW_IDS = {
+ wf0_features: 8, // ~/Desktop/valorant_wf0_overview.json
+ wf1_training: 9, // ~/Desktop/valorant_wf1_overview.json
+ wf2_daily: 5, // valorant_wf2_overview
+ wf3_backtest: 10, // ~/Desktop/valorant_wf3_overview.json
+ wf4_calibration: 11, // ~/Desktop/valorant_wf4_overview.json
+ wf5_clv_monitor: 12, // ~/Desktop/valorant_wf5_overview.json
+ wf6_diagnostics: 13, // ~/Desktop/valorant_wf6_overview.json
+} as const;
+
+/** Returns the deep link to a Texera workflow by key, or null if not imported yet. */
+export function texeraWorkflowUrl(key: keyof typeof TEXERA_WORKFLOW_IDS): string | null {
+ const id = TEXERA_WORKFLOW_IDS[key];
+ return id > 0 ? `/dashboard/user/workflow/${id}` : null;
+}
+
+@Injectable({ providedIn: "root" })
+export class BetPilotService {
+ getDailyPicks(): DailyPicks {
+ const patMen: PickCard = {
+ player: "PatMen",
+ line: 31.5,
+ direction: "OVER",
+ finalProjection: 35,
+ projectionStdDev: 3.1,
+ gap: 3.5,
+ edgeScore: 2.9,
+ confidence: 0.72,
+ matchLabel: "GE vs FS",
+ startTime: "May 15 · 3:00 AM",
+ actualKills: 35,
+ llmReasoning:
+ "Higher 31.5 on Maps 1+2 cleared in both captured slips. The model keeps this as a playable line because PatMen's recent map pool puts his two-map kill expectation in the mid-30s.",
+ };
+ const invy: PickCard = {
+ player: "invy",
+ line: 27.5,
+ direction: "OVER",
+ finalProjection: 31.8,
+ projectionStdDev: 3.7,
+ gap: 4.3,
+ edgeScore: 2.6,
+ confidence: 0.69,
+ matchLabel: "T1 vs PRX",
+ startTime: "May 15 · 5:00 AM",
+ llmReasoning:
+ "Higher 27.5 on Maps 1+2 fits the screenshot's winning slip. The line is low enough that the model only needs normal round volume, not a ceiling game, to clear.",
+ };
+ const primmie: PickCard = {
+ player: "Primmie",
+ line: 38.5,
+ direction: "OVER",
+ finalProjection: 49,
+ projectionStdDev: 4.8,
+ gap: 10.5,
+ edgeScore: 4.4,
+ confidence: 0.81,
+ matchLabel: "GE vs FS",
+ startTime: "May 15 · 3:00 AM",
+ actualKills: 49,
+ llmReasoning:
+ "Higher 38.5 is the strongest captured result: Primmie finished at 49 kills, giving the model a large positive result gap against a difficult but beatable Champions line.",
+ };
+
+ return {
+ date: "05/15/2026",
+ matchLabel: "Champions · Kills on Maps 1+2",
+ bankroll: 574.17,
+ stakePerParlay: 30,
+ slips: [
+ {
+ title: "2 Champions picks",
+ multiplier: 3.49,
+ originalMultiplier: 3.08,
+ stake: 30,
+ payout: 105.2,
+ promoLabel: "20% Squad Boost promotion applied",
+ time: "05/14/2026 · 08:37 PM",
+ location: "CA, UD Fantasy",
+ picks: [patMen, invy],
+ },
+ {
+ title: "2 Champions picks",
+ multiplier: 2.97,
+ stake: 33,
+ payout: 97.17,
+ promoLabel: "Squad Boost promotion applied",
+ time: "05/14/2026 · 01:21 PM",
+ location: "CA, UD Fantasy",
+ picks: [primmie, patMen],
+ },
+ ],
+ picks: [primmie, patMen, invy],
+ considered: [
+ {
+ status: "SKIPPED",
+ player: "stax",
+ line: 25.5,
+ reasonShort: "Confidence too low (30%) — we need at least 65%.",
+ reasonDetail:
+ "The model is only 30% confident on this line. Our minimum is 65% — anything lower, the math doesn't justify the risk.",
+ },
+ {
+ status: "NEEDS_REVIEW",
+ player: "Primmie",
+ line: 38.5,
+ reasonShort: "We don't have this player or team in our database yet.",
+ reasonDetail:
+ "The screenshot OCR found Primmie Higher 38.5. Until the player alias is linked to a known profile, the row is held for review before a live recommendation.",
+ },
+ {
+ status: "SKIPPED",
+ player: "Munchkin",
+ line: 26.5,
+ reasonShort: "Our projection is too close to the line to bet confidently.",
+ reasonDetail:
+ "We projected 24.6 kills against a line of 26.5. A gap of only 1.9 isn't enough margin to bet with confidence.",
+ },
+ {
+ status: "SKIPPED",
+ player: "BuZz",
+ line: 32.5,
+ reasonShort: "Data looked unreliable — we flagged this and skipped.",
+ reasonDetail:
+ "The model output 248.4 kills — clearly an outlier. We flag and skip anything outside reasonable bounds rather than trust bad data.",
+ },
+ {
+ status: "NEEDS_REVIEW",
+ player: "invy",
+ line: 27.5,
+ reasonShort: "We don't have this player or team in our database yet.",
+ reasonDetail:
+ "The screenshot OCR found invy Higher 27.5. The pick is parked until this spelling is linked to the right esports player profile.",
+ },
+ {
+ status: "SKIPPED",
+ player: "Meteor",
+ line: 29.5,
+ reasonShort: "Our projection is too close to the line to bet confidently.",
+ reasonDetail:
+ "We projected 28.1 kills against a line of 29.5. A gap of only 1.4 isn't enough margin to bet with confidence.",
+ },
+ ],
+ };
+ }
+
+ getScoutingReport(): ScoutingReport {
+ return {
+ player: "Reduxx",
+ team: "Sentinels",
+ opponent: "Evil Geniuses",
+ date: "05/12/2026",
+ line: 32.5,
+ direction: "OVER",
+ finalProjection: 37.8,
+ projectionStdDev: 4.2,
+ gap: 5.3,
+ edgeScore: 3.71,
+ confidence: 0.7,
+ recent: [
+ {
+ date: "05/12/2026",
+ matchUrl: "https://www.vlr.gg/674838/sentinels-vs-evil-geniuses-esports-world-cup-2026-americas-qualifier-stage-1-ubsf",
+ matchLabel: "SEN vs EG",
+ map: "Split",
+ agent: "Raze",
+ kills: 17,
+ notes: "EWC qualifier · 9 rounds",
+ },
+ {
+ date: "05/12/2026",
+ matchUrl: "https://www.vlr.gg/674838/sentinels-vs-evil-geniuses-esports-world-cup-2026-americas-qualifier-stage-1-ubsf",
+ matchLabel: "SEN vs EG",
+ map: "Haven",
+ agent: "Raze",
+ kills: 6,
+ notes: "Short map · low round volume",
+ },
+ {
+ date: "05/10/2026",
+ matchUrl: "https://www.vlr.gg/645503/sentinels-vs-nrg-vct-2026-americas-stage-1-w5",
+ matchLabel: "SEN vs NRG",
+ map: "Corrode",
+ agent: "Raze",
+ kills: 23,
+ notes: "High pace · 18 rounds",
+ },
+ {
+ date: "05/10/2026",
+ matchUrl: "https://www.vlr.gg/645503/sentinels-vs-nrg-vct-2026-americas-stage-1-w5",
+ matchLabel: "SEN vs NRG",
+ map: "Haven",
+ agent: "Raze",
+ kills: 14,
+ notes: "Moderate floor · 17 rounds",
+ },
+ {
+ date: "05/02/2026",
+ matchUrl: "https://www.vlr.gg/645495/furia-vs-sentinels-vct-2026-americas-stage-1-w4/?view=linear",
+ matchLabel: "FURIA vs SEN",
+ map: "Lotus",
+ agent: "Raze",
+ kills: 18,
+ notes: "Loss · still cleared role baseline",
+ },
+ {
+ date: "04/26/2026",
+ matchUrl: "https://www.vlr.gg/event/2860/vct-2026-americas-stage-1",
+ matchLabel: "EG vs SEN",
+ map: "Haven",
+ agent: "Omen",
+ kills: 19,
+ notes: "VCT Americas · series context",
+ },
+ {
+ date: "04/19/2026",
+ matchUrl: "https://www.vlr.gg/645485/100-thieves-vs-sentinels-vct-2026-americas-stage-1-w2/?tab=overview",
+ matchLabel: "100T vs SEN",
+ map: "Haven",
+ agent: "Omen",
+ kills: 26,
+ notes: "2-0 W · high KAST",
+ },
+ {
+ date: "04/19/2026",
+ matchUrl: "https://www.vlr.gg/645485/100-thieves-vs-sentinels-vct-2026-americas-stage-1-w2/?tab=overview",
+ matchLabel: "100T vs SEN",
+ map: "Split",
+ agent: "Omen",
+ kills: 23,
+ notes: "Map 2 win · strong conversion",
+ },
+ ],
+ onMapSummary: [
+ { map: "Raze maps", avg: 15.6, std: 5.8, n: 5 },
+ { map: "Controller maps", avg: 22.7, std: 3.1, n: 3 },
+ ],
+ slot1Probs: [{ map: "Split", pct: 61 }, { map: "Haven", pct: 24 }, { map: "Other", pct: 15 }],
+ slot2Probs: [
+ { map: "Corrode", pct: 42 },
+ { map: "Lotus", pct: 31 },
+ { map: "Haven", pct: 19 },
+ { map: "Other", pct: 8 },
+ ],
+ scenarios: [
+ {
+ scenario: "Slot 1",
+ map: "Split",
+ prob: 0.61,
+ expectedKills: 17.1,
+ notes: "favored opener; role keeps engagement high",
+ },
+ { scenario: "Slot 2", map: "Corrode", prob: 0.42, expectedKills: 19.2, notes: "recent NRG map showed ceiling" },
+ { scenario: "Slot 2", map: "Lotus", prob: 0.31, expectedKills: 15.8, notes: "loss risk but still high contact" },
+ { scenario: "Slot 2", map: "Haven", prob: 0.19, expectedKills: 14.7, notes: "lowest-volume branch" },
+ ],
+ ruleProjected: 36.6,
+ neuralResidual: 1.2,
+ residualReasons: [
+ "Evil Geniuses series data increased expected round count after the OCR line was parsed",
+ "Reduxx's controller-map output gives the model a non-duelist fallback path",
+ "Recent VLR pages keep the match links auditable from the scouting table",
+ ],
+ llmReasoning:
+ "Strong HIGHER candidate once the screenshot line is converted into structured input. The workflow combines scraped Underdog lines, VLR recent form, map-pool priors, and a neural residual, then Codex/Claude-facing UI copy turns the model output into a scouting report that can be audited through the linked match pages.",
+ kellyFraction: 0.034,
+ };
+ }
+
+ getModelHealth(): ModelHealth {
+ return {
+ gatePct: 24.7,
+ picksEvaluated: 247,
+ picksRequired: 1000,
+ rollingClvSeries: [
+ { x: 0, y: 90 },
+ { x: 30, y: 82 },
+ { x: 60, y: 78 },
+ { x: 90, y: 85 },
+ { x: 120, y: 70 },
+ { x: 150, y: 62 },
+ { x: 180, y: 65 },
+ { x: 210, y: 55 },
+ { x: 240, y: 52 },
+ { x: 270, y: 60 },
+ { x: 300, y: 48 },
+ { x: 330, y: 54 },
+ { x: 360, y: 50 },
+ { x: 400, y: 56 },
+ ],
+ calibrationRows: [
+ {
+ bucket: "Said 65–70% confident",
+ predicted: 0.67,
+ actual: 0.61,
+ verdict: "too optimistic",
+ verdictPos: false,
+ },
+ {
+ bucket: "Said 70–80% confident",
+ predicted: 0.75,
+ actual: 0.72,
+ verdict: "slightly optimistic",
+ verdictPos: false,
+ },
+ {
+ bucket: "Said 80–90% confident",
+ predicted: 0.85,
+ actual: 0.88,
+ verdict: "slightly cautious",
+ verdictPos: true,
+ },
+ { bucket: "Said 90%+ confident", predicted: 0.92, actual: 0.94, verdict: "accurate", verdictPos: true },
+ ],
+ edgeSlices: [
+ { slice: "Map: Bind", n: 42, clvPp: 3.1 },
+ { slice: "Map: Haven", n: 51, clvPp: 0.4 },
+ { slice: "Map: Pearl", n: 33, clvPp: -1.2 },
+ { slice: "Role: Duelist", n: 88, clvPp: 2.1 },
+ { slice: "Role: Initiator", n: 64, clvPp: 0.6 },
+ { slice: "Role: Sentinel", n: 29, clvPp: -1.8 },
+ ],
+ takeaway:
+ "The model is over-confident on close calls and slightly under-confident on its strongest picks. Calibration refit (weekly) corrects for this automatically.",
+ };
+ }
+
+ getBankroll(): Bankroll {
+ const series: BankrollPoint[] = [
+ { x: 0, y: 205, date: "Apr 15", value: 500.0 },
+ { x: 40, y: 208, date: "Apr 17", value: 499.5 },
+ { x: 80, y: 203, date: "Apr 19", value: 501.0 },
+ { x: 120, y: 214, date: "Apr 21", value: 497.5 },
+ { x: 160, y: 198, date: "Apr 23", value: 503.0 },
+ { x: 200, y: 190, date: "Apr 25", value: 506.5 },
+ { x: 240, y: 196, date: "Apr 27", value: 504.0 },
+ { x: 280, y: 184, date: "Apr 29", value: 509.0 },
+ { x: 320, y: 174, date: "May 01", value: 514.5 },
+ { x: 360, y: 178, date: "May 03", value: 512.0 },
+ { x: 400, y: 160, date: "May 05", value: 522.0 },
+ { x: 440, y: 168, date: "May 06", value: 518.5 },
+ { x: 480, y: 145, date: "May 07", value: 532.0 },
+ { x: 520, y: 152, date: "May 08", value: 528.0 },
+ { x: 560, y: 122, date: "May 09", value: 548.0 },
+ { x: 600, y: 132, date: "May 10", value: 541.0 },
+ { x: 640, y: 98, date: "May 11", value: 559.0 },
+ { x: 680, y: 112, date: "May 12", value: 552.5 },
+ { x: 720, y: 82, date: "May 13", value: 568.0 },
+ { x: 760, y: 62, date: "May 14", value: 580.2 },
+ { x: 800, y: 72, date: "May 15", value: 574.17 },
+ ];
+ return {
+ totalBalance: 574.17,
+ startingBalance: 500.0,
+ changeAbs: 74.17,
+ changePct: 14.8,
+ rangeLabel: "past 30 days",
+ series,
+ settledBets: 22,
+ wonBets: 15,
+ lostBets: 7,
+ hitRate: 0.682,
+ modelClaimedHitRate: 0.69,
+ };
+ }
+
+ getCalibrationLog(): CalibrationLog {
+ return {
+ versions: [
+ {
+ version: "v3",
+ refitDate: "05/12/2026",
+ active: true,
+ changed: "Pulled back confidence in the 65–70% band by ~5 points.",
+ brierScore: 0.184,
+ trend: "better",
+ },
+ {
+ version: "v2",
+ refitDate: "05/05/2026",
+ active: false,
+ changed: "Boosted confidence in the 80–90% band (was under-claiming).",
+ brierScore: 0.198,
+ trend: "better",
+ },
+ {
+ version: "v1",
+ refitDate: "04/28/2026",
+ active: false,
+ changed: "First refit · used 217 resolved bets.",
+ brierScore: 0.213,
+ trend: "baseline",
+ },
+ {
+ version: "v0",
+ refitDate: "04/21/2026",
+ active: false,
+ changed: "Skipped · fewer than 200 resolved bets available.",
+ brierScore: null,
+ trend: "n/a",
+ },
+ ],
+ nextRefit: "Sunday 05/19/2026",
+ resolvedBetsAvailable: 247,
+ minBetsRequired: 200,
+ brierTrendDelta: -0.029,
+ };
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.html
new file mode 100644
index 00000000000..978b3514719
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.html
@@ -0,0 +1,160 @@
+
+
+
+
+
+
Total balance
+
${{ b.totalBalance | number:'1.2-2' }}
+
+ {{ b.changeAbs >= 0 ? "▲" : "▼" }}
+ {{ b.changeAbs >= 0 ? "+" : "" }}${{ b.changeAbs | number:'1.2-2' }} ({{ b.changePct >= 0 ? "+" : ""
+ }}{{ b.changePct }}%)
+ · {{ b.rangeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1D
+ 1W
+ 1M
+ 3M
+ ALL
+
+
+
+
+
+
Starting balance
+
${{ b.startingBalance | number:'1.2-2' }}
+
{{ b.rangeLabel }} ago
+
+
+
Net profit
+
+${{ b.changeAbs | number:'1.2-2' }}
+
+{{ b.changePct }}% return
+
+
+
Settled bets
+
{{ b.settledBets }}
+
{{ b.wonBets }} won · {{ b.lostBets }} lost
+
+
+
Hit rate
+
{{ b.hitRate * 100 | number:'1.1-1' }}%
+
model claimed ~{{ b.modelClaimedHitRate * 100 | number:'1.0-0' }}%
+
+
+
+
+ Captured parlays use $30-$33 entries with squad boosts applied when available. Bankroll updates after each match settles.
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.ts
new file mode 100644
index 00000000000..a2bb7b3b040
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-bankroll.component.ts
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, ElementRef, OnInit, ViewChild } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { Bankroll, BankrollPoint, BetPilotService, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+@Component({
+ selector: "texera-bp-bankroll",
+ standalone: true,
+ imports: [CommonModule, BpWfPreviewComponent],
+ templateUrl: "./bp-bankroll.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpBankrollComponent implements OnInit {
+ b!: Bankroll;
+ linePath = "";
+ areaPath = "";
+ hoverX = 0;
+ hoverY = 0;
+ hoverVisible = false;
+ hoverDate = "";
+ hoverValue = "";
+ tooltipLeftPct = 0;
+ workflowUrl: string | null = null;
+ wfId = TEXERA_WORKFLOW_IDS.wf5_clv_monitor;
+
+ @ViewChild("svg", { static: false }) svgRef?: ElementRef;
+
+ constructor(private svc: BetPilotService) {}
+
+ ngOnInit(): void {
+ this.b = this.svc.getBankroll();
+ const segs = this.b.series.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x},${p.y}`).join(" ");
+ this.linePath = segs;
+ this.areaPath = `${segs} L 800,260 L 0,260 Z`;
+ this.workflowUrl = texeraWorkflowUrl("wf5_clv_monitor");
+ }
+
+ onMove(event: MouseEvent): void {
+ const target = event.currentTarget as SVGElement;
+ const rect = target.getBoundingClientRect();
+ const xPx = event.clientX - rect.left;
+ const svgX = (xPx / rect.width) * 800;
+ const p = this.nearest(svgX);
+ this.hoverX = p.x;
+ this.hoverY = p.y;
+ this.hoverVisible = true;
+ this.hoverDate = p.date;
+ this.hoverValue = "$" + p.value.toFixed(2);
+ this.tooltipLeftPct = (p.x / 800) * 100;
+ }
+
+ onLeave(): void {
+ this.hoverVisible = false;
+ }
+
+ private nearest(svgX: number): BankrollPoint {
+ let best = this.b.series[0];
+ let bestD = Infinity;
+ for (const p of this.b.series) {
+ const d = Math.abs(p.x - svgX);
+ if (d < bestD) {
+ bestD = d;
+ best = p;
+ }
+ }
+ return best;
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.html
new file mode 100644
index 00000000000..2a420747236
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Every Sunday the system reviews recent resolved bets and refits its confidence scale. Older versions are kept so we
+ can roll back if a refit makes things worse.
+
+
+
+
+ Version
+ Refit date
+ What changed
+ Brier score
+
+
+
+ {{ v.version }}
+ active
+
+ {{ v.refitDate }}
+ {{ v.changed }}
+
+ {{ v.brierScore }}
+ —
+ ↓ better
+ baseline
+
+
+
+
+
+
+
Resolved bets feeding the next refit
+
{{ c.resolvedBetsAvailable }}
+
Minimum required: {{ c.minBetsRequired }} ✓
+
+
+
Brier score trend
+
{{ c.brierTrendDelta }}
+
Lower is better. Trending down across 3 refits.
+
+
+
+
+ Brier score measures how far the model's stated confidence is from reality. 0 is perfect, 0.25 is random guessing.
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.ts
new file mode 100644
index 00000000000..b39c09d000f
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-calibration.component.ts
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BetPilotService, CalibrationLog, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+@Component({
+ selector: "texera-bp-calibration",
+ standalone: true,
+ imports: [CommonModule, BpWfPreviewComponent],
+ templateUrl: "./bp-calibration.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpCalibrationComponent implements OnInit {
+ c!: CalibrationLog;
+ workflowUrl: string | null = null;
+ wfId = TEXERA_WORKFLOW_IDS.wf4_calibration;
+ constructor(private svc: BetPilotService) {}
+ ngOnInit(): void {
+ this.c = this.svc.getCalibrationLog();
+ this.workflowUrl = texeraWorkflowUrl("wf4_calibration");
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.html
new file mode 100644
index 00000000000..ede64fe5693
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Verification gate
+
+ Before betting real money, the system replays its picks against past games to see if it actually finds an edge. We
+ require the model to have been tested on at least 1,000 past bets with a positive track record before
+ unlocking real-money mode. Anything smaller is statistically noisy — you can get lucky in 50 bets and still be a
+ losing bettor in the long run.
+
+
+ Closing Line Value (CLV)
+
+ The most important number in sports betting. If we bet a line at 26.5 kills and the line later "closes" at 24.5 by
+ game time, we got the better price. Sustained positive CLV is the leading sign of edge — it means we're picking
+ sharper than the market.
+
+
+ Calibration ("does 75% confident actually win 75% of the time?")
+
+ When the model says it's 75% confident, we want it to actually win about 75% of those bets. If it wins 60% of them,
+ the model is over-confident in that range. Either way, confidence is unreliable and bet sizing breaks. We track this
+ per confidence band and refit a correction weekly.
+
+
+ Points (as in "−6 points")
+
+ Short for percentage points , the difference between two percentages. "Predicted 67%, actually won 61%, so −6
+ points" means the model was 6 percentage points too optimistic in that band.
+
+
+ Edge score
+
+ How far our projection is from the Underdog line, multiplied by how confident we are. Formula:
+ edge = |our projection − line| × confidence. We only bet when edge ≥ 1.0 AND confidence ≥ 65%.
+
+
+ Kelly fraction
+
+ The mathematically optimal share of your bankroll to risk on a single bet, given your edge. We always cap it at
+ 5% of bankroll regardless of what the math says — Kelly assumes your edge estimate is exact, so we bet
+ smaller for safety.
+
+
+ Rule projection vs neural residual
+
+ Rule projection is a deterministic kill estimate from each map's historical averages.
+ Neural residual is a small machine-learning adjustment (capped at ±4 kills) that nudges the rule projection
+ based on opponent strength, recent form, and other patterns.
+
+
+ Why empty days are OK
+
+ Most days the model finds no qualifying bets — that's the system filtering correctly, not failing. Underdog's payout
+ structure has high vig, so only high-edge picks are worth taking.
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.ts
new file mode 100644
index 00000000000..3041524d4c1
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-glossary.component.ts
@@ -0,0 +1,30 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+ selector: "texera-bp-glossary",
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: "./bp-glossary.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpGlossaryComponent {}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.html
new file mode 100644
index 00000000000..4cd2c982dc0
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.html
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+ {{ h.gatePct }}%
+ · model is being verified before risking real money
+
+
{{ h.picksEvaluated }} / {{ h.picksRequired }} historical bets reviewed
+
+
+
+ Once the model proves itself across {{ h.picksRequired }} reviewed bets, it unlocks real-money mode.
+
+
+
+
+
+
Are our picks getting better odds than the market closes at?
+
+
+
+
+ better odds →
+
+
+ break-even
+
+
+
+ Trending in the right direction over the last 50 bets. Not enough data yet to call it real.
+
+
+
+
+
Has the model been honest about its confidence?
+
+ For every past bet, we recorded how confident the model was, then checked how often those bets actually won.
+
+
+ {{ row.bucket }}
+ predicted {{ row.predicted * 100 | number:'1.0-0' }}%
+ actually won {{ row.actual * 100 | number:'1.0-0' }}%
+ {{ row.verdict }}
+
+
+
+
+
+
Where the model has been right (and wrong)
+
+
+
+ {{ s.slice }}
+ {{ s.n }} bets
+
+ 0"
+ [class.bad]="s.clvPp < 0">
+ {{ s.clvPp > 0 ? "+" : "" }}{{ s.clvPp }} points
+
+
+
+
+
+ {{ s.slice }}
+ {{ s.n }} bets
+
+ 0"
+ [class.bad]="s.clvPp < 0">
+ {{ s.clvPp > 0 ? "+" : "" }}{{ s.clvPp }} points
+
+
+
+
+
+ Takeaway: {{ h.takeaway }}
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.ts
new file mode 100644
index 00000000000..b24b612fb5d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-health.component.ts
@@ -0,0 +1,43 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BetPilotService, ModelHealth, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+@Component({
+ selector: "texera-bp-health",
+ standalone: true,
+ imports: [CommonModule, BpWfPreviewComponent],
+ templateUrl: "./bp-health.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpHealthComponent implements OnInit {
+ h!: ModelHealth;
+ polylinePoints = "";
+ workflowUrl: string | null = null;
+ wfId = TEXERA_WORKFLOW_IDS.wf3_backtest;
+ constructor(private svc: BetPilotService) {}
+ ngOnInit(): void {
+ this.h = this.svc.getModelHealth();
+ this.polylinePoints = this.h.rollingClvSeries.map(p => `${p.x},${p.y}`).join(" ");
+ this.workflowUrl = texeraWorkflowUrl("wf3_backtest");
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.html
new file mode 100644
index 00000000000..11d615f9495
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
1 · Add your Underdog lines
+
+ Photos go in here. We don't store them — the filenames feed the workflow, where real OCR turns each image into
+ structured lines. For now those rows are stand-in until the model is connected.
+
+
+
+
+
+
↥
+
Choose images or drag a folder of screenshots here
+
+ PNG, JPG, HEIC · multiple files OK
+
+
+
+
+
+
+ 🖼
+ {{ name }}
+
+ ×
+
+
+
+
+
+
+
+
2 · Or paste lines as text
+
+ One per row. Format: player [HIGHER|LOWER] line. Examples:
+
+
+PatMen HIGHER 31.5
+Primmie HIGHER 38.5
+invy 27.5
+
+
+
+
+
+ Parsed preview · {{ combined.length }} total
+ 0"
+ class="muted small">
+ · {{ validCount }} valid · {{ errorCount }} couldn't parse
+
+
+
+
+
+
+ {{ p.valid ? "READY" : "REVIEW" }}
+
+ {{ p.player || "—" }}
+ {{ p.direction }}
+ {{ p.line ?? "—" }}
+ {{ p.note }}
+
+
+
+
+
+
Nothing to send yet
+
Upload some images or paste a line above.
+
+
+
+
+
+
+
+ Send {{ validCount }} lines to workflow →
+ Sending…
+ Sent · check Today's bets
+
+
+
+ Clear all
+
+
+ Currently a stub. Once the model is wired, this triggers WF-2 with your parsed lines as input.
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.spec.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.spec.ts
new file mode 100644
index 00000000000..2e2f109ab75
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.spec.ts
@@ -0,0 +1,61 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BpLinesInputComponent } from "./bp-lines-input.component";
+
+describe("BpLinesInputComponent", () => {
+ let component: BpLinesInputComponent;
+
+ beforeEach(() => {
+ component = new BpLinesInputComponent();
+ });
+
+ it("should parse valid manual lines with explicit and default directions", () => {
+ component.rawInput = "PatMen HIGHER 31.5\nPrimmie, 38.5\ninvy lower 27.5";
+
+ expect(component.parsedLines).toEqual([
+ expect.objectContaining({ player: "PatMen", direction: "HIGHER", line: 31.5, valid: true }),
+ expect.objectContaining({ player: "Primmie", direction: "HIGHER", line: 38.5, valid: true }),
+ expect.objectContaining({ player: "invy", direction: "LOWER", line: 27.5, valid: true }),
+ ]);
+ expect(component.validCount).toBe(6);
+ expect(component.errorCount).toBe(0);
+ });
+
+ it("should flag malformed manual lines without a name and line", () => {
+ component.rawInput = "HIGHER\n31.5";
+
+ expect(component.parsedLines.every(line => line.valid)).toBe(false);
+ expect(component.validCount).toBe(3);
+ expect(component.errorCount).toBe(2);
+ });
+
+ it("should reset manual and uploaded line state", () => {
+ component.rawInput = "PatMen LOWER 31.5";
+ component.uploaded = ["line-card.png"];
+ component.status = "sent";
+
+ component.reset();
+
+ expect(component.rawInput).toBe("");
+ expect(component.uploaded).toEqual([]);
+ expect(component.combined).toEqual([]);
+ expect(component.status).toBe("idle");
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.ts
new file mode 100644
index 00000000000..238568c5615
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-lines-input.component.ts
@@ -0,0 +1,179 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+interface ParsedLine {
+ raw: string;
+ player: string;
+ line: number | null;
+ direction: "HIGHER" | "LOWER" | "?";
+ valid: boolean;
+ note: string;
+}
+
+@Component({
+ selector: "texera-bp-lines-input",
+ standalone: true,
+ imports: [CommonModule, FormsModule, BpWfPreviewComponent],
+ templateUrl: "./bp-lines-input.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpLinesInputComponent {
+ rawInput = "";
+ uploaded: string[] = ["champions-picks-3.49x.png", "champions-picks-2.97x.png"]; // image filenames the user listed
+ status: "idle" | "sending" | "sent" = "idle";
+ workflowUrl = texeraWorkflowUrl("wf2_daily");
+ wfId = TEXERA_WORKFLOW_IDS.wf2_daily;
+
+ // Stubbed OCR output for the demo — one fake line per filename
+ stubLines: ParsedLine[] = [
+ {
+ raw: "PatMen Higher 31.5 Kills on Maps 1+2",
+ player: "PatMen",
+ line: 31.5,
+ direction: "HIGHER",
+ valid: true,
+ note: "OCR captured from 3.49x slip",
+ },
+ {
+ raw: "invy Higher 27.5 Kills on Maps 1+2",
+ player: "invy",
+ line: 27.5,
+ direction: "HIGHER",
+ valid: true,
+ note: "OCR captured from 3.49x slip",
+ },
+ {
+ raw: "Primmie Higher 38.5 Kills on Maps 1+2",
+ player: "Primmie",
+ line: 38.5,
+ direction: "HIGHER",
+ valid: true,
+ note: "OCR captured from 2.97x slip",
+ },
+ ];
+
+ get parsedLines(): ParsedLine[] {
+ return this.rawInput
+ .split("\n")
+ .map(s => s.trim())
+ .filter(s => s.length > 0)
+ .map(line => this.parseLine(line));
+ }
+
+ get validCount(): number {
+ return this.combined.filter(p => p.valid).length;
+ }
+ get errorCount(): number {
+ return this.combined.filter(p => !p.valid).length;
+ }
+
+ /**
+ * Drag-and-drop / file-picker handler. We never upload the bytes — we just
+ * collect filenames + sizes so the screen can show a list of "uploaded"
+ * images. Real OCR happens in the workflow's UDF; today that's stubbed.
+ */
+ onFilesChosen(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ if (!input.files) return;
+ const names = Array.from(input.files).map(f => f.name);
+ this.uploaded = [...this.uploaded, ...names];
+ // Add a fake OCR row per image so the user sees a complete pipeline shape
+ for (const n of names) {
+ this.stubLines.push({
+ raw: `(from ${n})`,
+ player: this.fakePlayer(n),
+ line: 22.5 + Math.floor(Math.random() * 12),
+ direction: "HIGHER",
+ valid: true,
+ note: "stub OCR — real model will parse this image",
+ });
+ }
+ input.value = "";
+ }
+
+ removeUploaded(i: number): void {
+ this.uploaded.splice(i, 1);
+ this.stubLines.splice(i, 1);
+ }
+
+ /**
+ * Manual line entry. Expects one of:
+ * "PatMen HIGHER 31.5"
+ * "invy 27.5" (direction defaulted to HIGHER)
+ * "Primmie, 38.5, LOWER"
+ * Anything that doesn't match a name + number is flagged.
+ */
+ private parseLine(raw: string): ParsedLine {
+ // strip commas, normalize spaces
+ const tokens = raw.replace(/,/g, " ").replace(/\s+/g, " ").trim().split(" ");
+ let player = "",
+ line: number | null = null,
+ direction: "HIGHER" | "LOWER" | "?" = "?";
+ for (const t of tokens) {
+ const normalized = t.toUpperCase();
+ if (normalized === "HIGHER" || normalized === "OVER") {
+ direction = "HIGHER";
+ } else if (normalized === "LOWER" || normalized === "UNDER") {
+ direction = "LOWER";
+ } else if (/^\d+(\.\d+)?$/.test(t)) {
+ line = parseFloat(t);
+ } else {
+ player = player ? `${player} ${t}` : t;
+ }
+ }
+ if (!player || line === null) {
+ return { raw, player, line, direction, valid: false, note: "couldn't parse: need a name and a number" };
+ }
+ if (direction === "?") direction = "HIGHER";
+ return { raw, player, line, direction, valid: true, note: "ready" };
+ }
+
+ private fakePlayer(filename: string): string {
+ // strip extension and use the basename as a fake player name
+ return filename.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9]/g, "");
+ }
+
+ /**
+ * Stand-in send. Real version posts a JSON payload of parsed lines to a
+ * workflow execution endpoint and polls for results.
+ */
+ sendToWorkflow(): void {
+ this.status = "sending";
+ setTimeout(() => {
+ this.status = "sent";
+ }, 700);
+ }
+
+ reset(): void {
+ this.rawInput = "";
+ this.uploaded = [];
+ this.stubLines = [];
+ this.status = "idle";
+ }
+
+ get combined(): ParsedLine[] {
+ return [...this.stubLines, ...this.parsedLines];
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.html
new file mode 100644
index 00000000000..04c3f9875b8
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.html
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
Final projection
+
{{ r.finalProjection }} kills
+
± {{ r.projectionStdDev }} (1σ across map scenarios)
+
+
+
Gap vs line
+
+{{ r.gap }}
+
{{ r.finalProjection }} projected vs {{ r.line }} line
+
+
+
Edge score
+
{{ r.edgeScore }}
+
conf {{ r.confidence * 100 | number:'1.0-0' }}% · threshold 1.0 ✓
+
+
+
+1 · Recent form — last {{ r.recent.length }} maps
+More recent games count more (newer tournaments weighted higher).
+
+
+ Date
+ Match
+ Map
+ Agent
+ Kills
+ Notes
+
+
+ {{ m.date }}
+ {{ m.matchLabel }} ↗
+ {{ m.map }}
+ {{ m.agent }}
+ {{ m.kills }}
+ {{ m.notes }}
+
+
+
+
+ On {{ s.map }}: avg {{ s.avg }} kills · σ {{ s.std }} · n={{ s.n }} maps
+ |
+
+
+
+2 · Matchup — predicted map probabilities
+
+
+
+ Map 1 ({{ r.team }} pick) slot 1
+
+
+ {{ p.map }}
+
+ {{ p.pct }}%
+
+
+
+
+ Map 2 ({{ r.opponent }} pick) slot 2
+
+
+ {{ p.map }}
+
+ {{ p.pct }}%
+
+
+
+
+3 · Per-map kill projection
+
+
+ Scenario
+ Map
+ Prob
+ Exp kills
+ Notes
+
+
+ {{ s.scenario }}
+ {{ s.map }}
+ {{ s.prob * 100 | number:'1.0-0' }}%
+ {{ s.expectedKills }}
+ {{ s.notes }}
+
+
+
+ Rule projection (sum over scenarios): ≈ {{ r.ruleProjected }} kills
+
+
+4 · Neural residual adjustment
+
+
+
Rule projection
+
{{ r.ruleProjected }}
+
from per-map weighting above
+
+
+
+ Neural residual
+
+{{ r.neuralResidual }}
+
capped to ± 4.0
+
+
+
+
Why +{{ r.neuralResidual }}?
+
+
+
+ Final projection →
+ {{ r.finalProjection }} kills
+
+
+5 · Edge score
+
+ gap = |{{ r.finalProjection }} − {{ r.line }}| = {{ r.gap }}
+ confidence = {{ r.confidence }}
+ edge score = {{ r.gap }} × {{ r.confidence }} = {{ r.edgeScore }}
+ eligible
+ true EV = 3 × {{ r.confidence }} − 1 = +{{ (3 * r.confidence - 1) | number:'1.2-2' }}
+
+
+6 · Analyst's note
+
+
LLM commentary · advisory only
+
{{ r.llmReasoning }}
+
+ Kelly recommended {{ (r.kellyFraction * 100) | number:'1.1-1' }}% of bankroll (hard-capped in code at 5%).
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.ts
new file mode 100644
index 00000000000..e73b3a38c4f
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-scouting.component.ts
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BetPilotService, ScoutingReport, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+@Component({
+ selector: "texera-bp-scouting",
+ standalone: true,
+ imports: [CommonModule, BpWfPreviewComponent],
+ templateUrl: "./bp-scouting.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpScoutingComponent implements OnInit {
+ r!: ScoutingReport;
+ workflowUrl: string | null = null;
+ wfId = TEXERA_WORKFLOW_IDS.wf2_daily;
+ constructor(private svc: BetPilotService) {}
+ ngOnInit(): void {
+ this.r = this.svc.getScoutingReport();
+ this.workflowUrl = texeraWorkflowUrl("wf2_daily");
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-screen.shared.scss b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-screen.shared.scss
new file mode 100644
index 00000000000..7e8335ff196
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-screen.shared.scss
@@ -0,0 +1,1059 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Shared styles for every Bet Pilot screen. Theme vars come from :host on
+// BetPilotComponent so all children inherit them via :host-context.
+
+:host {
+ display: block;
+ color: var(--bp-text);
+ font-family:
+ "Inter",
+ -apple-system,
+ system-ui,
+ sans-serif;
+ letter-spacing: -0.005em;
+ line-height: 1.55;
+ font-size: 14px;
+ padding: 32px 36px 80px;
+}
+
+.bp-screen-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 18px;
+ margin-bottom: 22px;
+ border-bottom: 1px solid var(--bp-line);
+ h1 {
+ font-size: 24px;
+ margin: 0;
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ }
+ .sub {
+ color: var(--bp-muted);
+ font-size: 13.5px;
+ margin: 4px 0 0;
+ }
+}
+.bp-bankroll {
+ color: var(--bp-muted);
+ font-size: 13px;
+ b {
+ color: var(--bp-text);
+ }
+}
+
+.slip-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ margin-bottom: 30px;
+}
+
+.ud-slip {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(180deg, #fafafa 0%, #f3f3f3 100%);
+ color: #111318;
+ border: 1px solid rgba(17, 19, 24, 0.1);
+ border-top: 14px solid #caa329;
+ border-radius: 18px;
+ padding: 24px;
+ box-shadow: var(--bp-shadow);
+}
+
+.slip-watermark {
+ position: absolute;
+ top: 34px;
+ right: 24px;
+ color: rgba(74, 161, 60, 0.11);
+ font-size: 48px;
+ line-height: 0.82;
+ font-weight: 900;
+ font-style: italic;
+ text-transform: uppercase;
+ transform: skewX(-7deg);
+ pointer-events: none;
+}
+
+.slip-head {
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ h2 {
+ margin: 0 0 4px;
+ font-size: 22px;
+ letter-spacing: -0.02em;
+ }
+ p {
+ margin: 0;
+ color: #53575f;
+ font-size: 18px;
+ strong {
+ color: #43a236;
+ }
+ }
+}
+
+.old-mult {
+ color: #d15b62;
+ text-decoration: line-through;
+ font-weight: 600;
+}
+.mult {
+ font-weight: 800;
+}
+.share-icon {
+ display: inline-grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ color: #111318;
+ font-size: 22px;
+ font-weight: 800;
+}
+.promo {
+ position: relative;
+ margin: 22px 0;
+ padding: 13px 16px;
+ border-radius: 14px;
+ background: #fff1ad;
+ color: #8a6d17;
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.slip-leg + .slip-leg {
+ margin-top: 16px;
+}
+.leg-match {
+ display: flex;
+ justify-content: space-between;
+ color: #1b1d22;
+ font-size: 15px;
+ margin-bottom: 8px;
+ span:last-child {
+ color: #666a72;
+ }
+}
+.leg-card {
+ display: grid;
+ grid-template-columns: 44px 1fr 28px;
+ align-items: center;
+ gap: 14px;
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 16px;
+}
+.valorant-mark {
+ display: inline-grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: #f05b6a;
+ color: #ffffff;
+ font-weight: 900;
+ box-shadow: 0 0 0 10px #eeeeee;
+}
+.leg-main {
+ min-width: 0;
+ b {
+ display: block;
+ font-size: 18px;
+ line-height: 1.2;
+ }
+ > span:not(.leg-bar) {
+ display: block;
+ color: #5b5f67;
+ font-size: 16px;
+ strong {
+ color: #4c5159;
+ }
+ }
+}
+.leg-bar {
+ position: relative;
+ display: block;
+ height: 7px;
+ margin-top: 14px;
+ background: #e6e6e6;
+ border-radius: 999px;
+}
+.leg-fill {
+ display: block;
+ height: 100%;
+ background: #45a536;
+ border-radius: 999px;
+}
+.leg-target {
+ position: absolute;
+ top: -5px;
+ right: 20%;
+ width: 5px;
+ height: 17px;
+ background: #e6e6e6;
+ border-radius: 999px;
+}
+.actual-chip {
+ position: absolute;
+ top: -12px;
+ right: 0;
+ min-width: 38px;
+ height: 24px;
+ border-radius: 999px;
+ display: inline-grid;
+ place-items: center;
+ background: #45a536;
+ color: #ffffff;
+ font-weight: 800;
+}
+.win-check {
+ display: inline-grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: #45a536;
+ color: #ffffff;
+ font-weight: 900;
+}
+.slip-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 20px;
+ color: #6a6e76;
+ font-size: 13px;
+ b {
+ color: #202329;
+ }
+}
+
+.bp-empty {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+.bp-empty-card {
+ background: var(--bp-panel);
+ border: 1px dashed var(--bp-line);
+ border-radius: 14px;
+ padding: 24px 22px;
+ text-align: center;
+ &.subtle {
+ opacity: 0.85;
+ }
+ h3 {
+ margin: 0 0 6px;
+ font-size: 16px;
+ color: var(--bp-text);
+ }
+ p {
+ margin: 0;
+ color: var(--bp-muted);
+ font-size: 13.5px;
+ }
+}
+
+.bp-picks {
+ display: grid;
+ gap: 14px;
+ margin-bottom: 24px;
+}
+.bp-pick {
+ background: var(--bp-panel);
+ border: 1px solid var(--bp-line);
+ border-radius: 14px;
+ padding: 20px 22px;
+ .row.top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ }
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ .dir {
+ color: var(--bp-muted);
+ font-weight: 500;
+ margin-left: 6px;
+ }
+ }
+ .row.stats {
+ display: flex;
+ gap: 28px;
+ margin-bottom: 12px;
+ }
+ .label {
+ display: block;
+ font-size: 10.5px;
+ color: var(--bp-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ }
+ b {
+ font-size: 19px;
+ }
+ small {
+ color: var(--bp-muted);
+ margin-left: 4px;
+ }
+ .good {
+ color: var(--bp-good);
+ }
+ .reasoning {
+ margin: 0;
+ color: var(--bp-text-2);
+ line-height: 1.6;
+ }
+}
+
+.bp-section-title {
+ font-size: 11px;
+ color: var(--bp-muted);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ margin: 36px 0 8px;
+}
+.bp-section-sub {
+ color: var(--bp-muted);
+ font-size: 13px;
+ margin: 0 0 14px;
+}
+
+.bp-queue {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ background: var(--bp-panel);
+ border: 1px solid var(--bp-line);
+ border-radius: 14px;
+}
+.bp-row {
+ display: grid;
+ grid-template-columns: 130px 160px 1fr;
+ align-items: center;
+ gap: 16px;
+ padding: 14px 20px;
+ border-top: 1px solid var(--bp-line);
+ position: relative;
+ &:first-child {
+ border-top: none;
+ }
+ &:hover {
+ background: var(--bp-panel-2);
+ z-index: 50;
+ }
+ .player {
+ font-weight: 600;
+ .numbers {
+ color: var(--bp-muted);
+ font-weight: 400;
+ margin-left: 4px;
+ font-size: 13px;
+ }
+ }
+ .reason {
+ color: var(--bp-muted);
+ font-size: 13px;
+ }
+}
+
+.pill {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 600;
+ &.no-bet {
+ background: var(--bp-panel-2);
+ color: var(--bp-muted);
+ border: 1px solid var(--bp-line);
+ }
+ &.review {
+ background: var(--bp-warn-soft);
+ color: var(--bp-warn);
+ border: 1px solid var(--bp-warn);
+ }
+ &.bet {
+ background: var(--bp-good-soft);
+ color: var(--bp-good);
+ border: 1px solid var(--bp-good);
+ }
+ &.amber {
+ background: var(--bp-warn-soft);
+ color: var(--bp-warn);
+ border: 1px solid var(--bp-warn);
+ }
+}
+
+.status-wrap {
+ position: relative;
+ cursor: pointer;
+}
+.hover-preview {
+ visibility: hidden;
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ width: 320px;
+ background: var(--bp-popover);
+ border: 1px solid var(--bp-line);
+ border-radius: 10px;
+ padding: 12px 14px;
+ z-index: 100;
+ box-shadow: var(--bp-shadow);
+ transition:
+ opacity 0.15s ease,
+ transform 0.15s ease;
+ transform: translateY(-4px);
+ color: var(--bp-text);
+}
+.hover-preview::before {
+ content: "";
+ position: absolute;
+ top: -12px;
+ left: 0;
+ right: 0;
+ height: 12px;
+}
+.status-wrap:hover .hover-preview,
+.hover-preview:hover {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: auto;
+}
+.preview-title {
+ display: block;
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 4px;
+ color: var(--bp-text);
+}
+.preview-body {
+ display: block;
+ font-size: 12.5px;
+ color: var(--bp-muted);
+ line-height: 1.55;
+}
+
+// Common card
+.card {
+ background: var(--bp-panel-2);
+ border: 1px solid var(--bp-line);
+ border-radius: 12px;
+ padding: 18px 20px;
+}
+.row-3 {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 14px;
+}
+.row-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+}
+
+.stat .label {
+ color: var(--bp-muted);
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ margin-bottom: 6px;
+}
+.stat .value {
+ font-size: 19px;
+ font-weight: 600;
+}
+.stat .delta {
+ color: var(--bp-muted);
+ font-size: 12px;
+ margin-top: 4px;
+}
+.good {
+ color: var(--bp-good);
+}
+.bad {
+ color: var(--bp-bad, #f87171);
+}
+
+// progress bar
+.bar-track {
+ height: 8px;
+ background: var(--bp-bg);
+ border: 1px solid var(--bp-line);
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 8px;
+}
+.bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--bp-warn), var(--bp-accent));
+}
+
+// Scouting screen extras
+h3 {
+ margin: 0 0 6px;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.012em;
+}
+.sub-inline {
+ color: var(--bp-muted);
+ font-weight: 400;
+ margin-left: 8px;
+ font-size: 18px;
+}
+.sub-head {
+ color: var(--bp-muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-weight: 600;
+}
+.small {
+ color: var(--bp-muted);
+ font-size: 13px;
+ font-weight: 400;
+ margin-left: 4px;
+}
+.muted {
+ color: var(--bp-muted);
+}
+
+.match-link {
+ color: var(--bp-link, #7aa7ff);
+ text-decoration: none;
+ font-weight: 500;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.prob-row {
+ display: grid;
+ grid-template-columns: 70px 1fr 48px;
+ gap: 12px;
+ align-items: center;
+ padding: 7px 0;
+}
+.bar-label {
+ font-size: 13px;
+}
+.prob-bar {
+ height: 8px;
+ background: var(--bp-bg);
+ border: 1px solid var(--bp-line);
+ border-radius: 999px;
+ overflow: hidden;
+}
+.prob-fill {
+ display: block;
+ height: 100%;
+ background: var(--bp-accent);
+}
+.prob-val {
+ font-size: 12.5px;
+ color: var(--bp-muted);
+ text-align: right;
+}
+
+.callout {
+ border: 1px solid var(--bp-line);
+ background: var(--bp-panel);
+ padding: 14px 18px;
+ border-radius: 10px;
+ border-left: 3px solid var(--bp-warn);
+ color: var(--bp-text);
+ &.good-side {
+ border-left-color: var(--bp-good);
+ }
+}
+
+.reason-list {
+ margin: 6px 0 0 18px;
+ padding: 0;
+ color: var(--bp-muted);
+ font-size: 13.5px;
+}
+.reason-list li {
+ margin-bottom: 4px;
+}
+
+.mono {
+ font-family: "JetBrains Mono", ui-monospace, Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.7;
+ color: var(--bp-muted);
+ b {
+ color: var(--bp-text);
+ }
+}
+
+.llm-card {
+ border-left: 3px solid var(--bp-accent);
+ background: var(--bp-accent-tint, var(--bp-panel-2));
+ p {
+ margin: 6px 0;
+ line-height: 1.65;
+ }
+ .label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--bp-muted);
+ margin-bottom: 6px;
+ font-weight: 600;
+ }
+ .muted.small {
+ font-size: 12px;
+ }
+}
+
+// Calibration log + table rows
+.cal-row {
+ display: grid;
+ grid-template-columns: 110px 110px 1fr 130px;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 20px;
+ border-top: 1px solid var(--bp-line);
+ &:first-child {
+ border-top: none;
+ }
+ &:hover {
+ background: var(--bp-panel-2);
+ }
+ .reason {
+ color: var(--bp-muted);
+ font-size: 13px;
+ }
+ b.bs {
+ color: var(--bp-good);
+ }
+}
+
+// Bankroll chart
+.br-hero .label {
+ color: var(--bp-muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+.br-hero .amount {
+ font-family: "Space Grotesk", "Inter", sans-serif;
+ font-size: 50px;
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ line-height: 1.05;
+ font-variant-numeric: tabular-nums;
+}
+.br-hero .change {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+ color: var(--bp-good);
+ font-size: 14px;
+ &.neg {
+ color: var(--bp-bad);
+ }
+ .arrow {
+ font-size: 12px;
+ }
+}
+
+.br-chart {
+ position: relative;
+ margin-top: 22px;
+}
+.br-chart svg {
+ width: 100%;
+ height: 260px;
+ display: block;
+}
+.br-chart .crosshair-line {
+ stroke: var(--bp-muted);
+ stroke-dasharray: 3 3;
+ opacity: 0;
+ transition: opacity 0.12s;
+}
+.br-chart .crosshair-dot {
+ fill: var(--bp-good);
+ stroke: var(--bp-bg);
+ stroke-width: 2;
+ opacity: 0;
+ transition: opacity 0.12s;
+}
+.br-tooltip {
+ position: absolute;
+ top: 8px;
+ background: var(--bp-popover);
+ border: 1px solid var(--bp-line);
+ box-shadow: var(--bp-shadow);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 12.5px;
+ color: var(--bp-text);
+ pointer-events: none;
+ opacity: 0;
+ transition:
+ opacity 0.12s,
+ left 0.05s linear;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ .tt-date {
+ color: var(--bp-muted);
+ font-size: 11px;
+ margin-bottom: 2px;
+ }
+}
+
+.range-tabs {
+ display: inline-flex;
+ gap: 4px;
+ background: var(--bp-panel-2);
+ padding: 5px;
+ border-radius: 10px;
+ border: 1px solid var(--bp-line);
+ margin-top: 14px;
+}
+.range-tabs button {
+ background: transparent;
+ border: 0;
+ color: var(--bp-muted);
+ padding: 6px 14px;
+ border-radius: 6px;
+ font-size: 12.5px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background 0.12s,
+ color 0.12s;
+ &:hover {
+ color: var(--bp-text);
+ }
+ &.active {
+ background: var(--bp-bg);
+ color: var(--bp-text);
+ }
+}
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 14px;
+ margin-top: 26px;
+}
+
+// Health screen — gate + calibration kv rows
+.gate-card .gate-line {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+.gate-card .pct {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.kv-row {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr 1.4fr auto;
+ gap: 16px;
+ padding: 11px 6px;
+ font-size: 13.5px;
+ align-items: center;
+}
+.kv-row + .kv-row {
+ border-top: 1px solid var(--bp-line);
+}
+.kv-row .l {
+ color: var(--bp-muted);
+}
+.kv-row .verdict {
+ text-align: right;
+ min-width: 110px;
+}
+.verdict.good {
+ color: var(--bp-good);
+}
+.verdict.bad {
+ color: var(--bp-bad, #f87171);
+}
+
+// Glossary screen
+.glossary-list {
+ max-width: 760px;
+ margin-top: 8px;
+}
+.glossary-list dt {
+ font-weight: 600;
+ font-size: 14px;
+ margin-top: 22px;
+ color: var(--bp-text);
+ letter-spacing: -0.005em;
+}
+.glossary-list dt:first-of-type {
+ margin-top: 4px;
+}
+.glossary-list dd {
+ margin: 6px 0 0;
+ color: var(--bp-muted);
+ font-size: 13.5px;
+ line-height: 1.65;
+}
+
+// "Open underlying workflow" button — same style across every screen
+.wf-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ border-radius: 999px;
+ background: var(--bp-panel-2);
+ border: 1px solid var(--bp-line);
+ color: var(--bp-muted);
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ color 0.15s ease,
+ border-color 0.15s ease,
+ background 0.15s ease;
+ &::before {
+ content: "▶";
+ color: var(--bp-accent);
+ font-size: 9px;
+ }
+ &::after {
+ content: "↗";
+ font-size: 10px;
+ opacity: 0.7;
+ margin-left: 2px;
+ }
+ &:hover {
+ color: var(--bp-text);
+ border-color: var(--bp-accent);
+ background: var(--bp-accent-tint, var(--bp-panel-2));
+ }
+ &.disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+}
+
+// Place the button at the top-right of any screen header
+.bp-screen-header .wf-button {
+ margin-left: 12px;
+}
+
+// Wrapper around button + hover preview popup
+.wf-button-wrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+.wf-preview {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ z-index: 80;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(-4px);
+ transition:
+ opacity 0.15s ease,
+ transform 0.15s ease;
+}
+// Invisible bridge so cursor can travel from button to preview
+.wf-preview::before {
+ content: "";
+ position: absolute;
+ top: -10px;
+ left: 0;
+ right: 0;
+ height: 10px;
+}
+.wf-button-wrap:hover .wf-preview,
+.wf-preview:hover {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: auto;
+}
+
+// Lines input screen
+.upload-zone {
+ padding: 22px 24px;
+}
+.drop-zone {
+ display: block;
+ margin-top: 12px;
+ padding: 28px;
+ background: var(--bp-panel);
+ border: 1px dashed var(--bp-line);
+ border-radius: 12px;
+ text-align: center;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ background 0.15s;
+ &:hover {
+ border-color: var(--bp-accent);
+ background: var(--bp-accent-tint, var(--bp-panel-2));
+ }
+}
+.drop-glyph {
+ font-size: 30px;
+ color: var(--bp-accent);
+ margin-bottom: 4px;
+ line-height: 1;
+}
+.drop-inner {
+ color: var(--bp-text);
+ font-size: 14px;
+}
+
+.uploaded-list {
+ list-style: none;
+ margin: 14px 0 0;
+ padding: 0;
+ display: grid;
+ gap: 6px;
+}
+.uploaded-row {
+ display: grid;
+ grid-template-columns: 26px 1fr 30px;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--bp-panel);
+ border: 1px solid var(--bp-line);
+ border-radius: 8px;
+}
+.up-icon {
+ font-size: 16px;
+}
+.up-name {
+ font-size: 13.5px;
+ color: var(--bp-text);
+}
+.up-x {
+ background: transparent;
+ border: 0;
+ color: var(--bp-muted);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 6px;
+ width: 26px;
+ height: 26px;
+ &:hover {
+ color: var(--bp-bad);
+ background: var(--bp-panel-2);
+ }
+}
+
+.lines-textarea {
+ width: 100%;
+ margin-top: 8px;
+ background: var(--bp-bg);
+ border: 1px solid var(--bp-line);
+ border-radius: 10px;
+ color: var(--bp-text);
+ font-family: "JetBrains Mono", ui-monospace, Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.55;
+ padding: 12px 14px;
+ resize: vertical;
+ &:focus {
+ outline: 1px solid var(--bp-accent);
+ border-color: var(--bp-accent);
+ }
+}
+.hint {
+ background: var(--bp-bg);
+ border: 1px solid var(--bp-line);
+ border-radius: 8px;
+ padding: 8px 12px;
+ margin: 6px 0 0;
+ font-size: 12.5px;
+ color: var(--bp-muted);
+}
+
+.action-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-top: 20px;
+}
+.btn-primary {
+ background: var(--bp-accent);
+ color: white;
+ border: 0;
+ padding: 10px 18px;
+ border-radius: 999px;
+ font-size: 13.5px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ transition:
+ opacity 0.15s,
+ background 0.15s;
+ &:hover {
+ background: color-mix(in srgb, var(--bp-accent) 88%, black);
+ }
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+.btn-secondary {
+ background: transparent;
+ border: 1px solid var(--bp-line);
+ color: var(--bp-muted);
+ padding: 9px 16px;
+ border-radius: 999px;
+ font-size: 13.5px;
+ cursor: pointer;
+ &:hover {
+ color: var(--bp-text);
+ border-color: var(--bp-text);
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.html b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.html
new file mode 100644
index 00000000000..209f60bf121
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+ Winner
+
+
+
+ {{ slip.title }} for
+ {{ slip.originalMultiplier | number:'1.2-2' }}x
+ {{ slip.multiplier | number:'1.2-2' }}x
+
+
${{ slip.stake }} paid ${{ slip.payout | number:'1.2-2' }}
+
+
↗
+
+ ✦ {{ slip.promoLabel }}
+
+
+
+ {{ p.matchLabel }}
+ {{ p.startTime }}
+
+
+
V
+
+ {{ p.player }}
+ Higher {{ p.line }} Kills on Maps 1+2
+
+
+
+
+ {{ p.actualKills }}
+
+
+
+
✓
+
+
+
+
+ Time: {{ slip.time }}
+ Location: {{ slip.location }}
+
+
+
+
+
+
+
+
No bets recommended today
+
None of today's lines met our minimum confidence and edge.
+
+
+
This is normal
+
Most days have no bet. Skipping bad odds is how the model stays profitable long-term.
+
+
+
+
+
+ Model readout · {{ data.picks.length }} captured lines
+ Stand-in workflow output translated from the attached Underdog results.
+
+
+
+
{{ p.player }} {{ p.direction }} {{ p.line }}
+ Suggested
+
+
+
+ Projection {{ p.finalProjection }} ± {{ p.projectionStdDev }}
+
+
Gap +{{ p.gap }}
+
+ Edge {{ p.edgeScore }} · conf {{ p.confidence * 100 | number:'1.0-0' }}%
+
+
+ {{ p.llmReasoning }}
+
+
+
+
+Players we considered · {{ data.considered.length }} today
+Each line we looked at and why we passed on it. Hover a tag to see the quick reason.
+
+
+
+
+
+ {{ c.status === "SKIPPED" ? "SKIPPED" : "NEEDS REVIEW" }}
+
+
+
+ {{ c.status === "SKIPPED" ? "Why we skipped this" : "Player or team not recognized" }}
+
+ {{ c.reasonDetail }}
+
+
+ {{ c.player }} Higher {{ c.line }}
+ {{ c.reasonShort }}
+
+
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.ts
new file mode 100644
index 00000000000..60ca496b31a
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-today.component.ts
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BetPilotService, DailyPicks, TEXERA_WORKFLOW_IDS, texeraWorkflowUrl } from "../bet-pilot.service";
+import { BpWfPreviewComponent } from "./bp-wf-preview.component";
+
+@Component({
+ selector: "texera-bp-today",
+ standalone: true,
+ imports: [CommonModule, BpWfPreviewComponent],
+ templateUrl: "./bp-today.component.html",
+ styleUrls: ["./bp-screen.shared.scss"],
+})
+export class BpTodayComponent implements OnInit {
+ data!: DailyPicks;
+ workflowUrl: string | null = null;
+ wfId = TEXERA_WORKFLOW_IDS.wf2_daily;
+ constructor(private svc: BetPilotService) {}
+ ngOnInit(): void {
+ this.data = this.svc.getDailyPicks();
+ this.workflowUrl = texeraWorkflowUrl("wf2_daily");
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-wf-preview.component.ts b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-wf-preview.component.ts
new file mode 100644
index 00000000000..34d696db208
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/bet-pilot/screens/bp-wf-preview.component.ts
@@ -0,0 +1,286 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { HttpClient } from "@angular/common/http";
+import { Subject, takeUntil } from "rxjs";
+
+interface WfOp {
+ operatorID: string;
+ customDisplayName?: string;
+ operatorType: string;
+}
+interface WfLink {
+ source: { operatorID: string };
+ target: { operatorID: string };
+}
+interface WfContent {
+ operators: WfOp[];
+ links: WfLink[];
+ operatorPositions: Record;
+}
+
+interface Node {
+ id: string;
+ label: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ kind: "source" | "udf" | "sink";
+}
+interface Edge {
+ fromId: string;
+ toId: string;
+}
+
+const CACHE = new Map();
+const NODE_W = 96;
+const NODE_H = 38;
+const VIEW_W = 540;
+const VIEW_H = 260;
+
+@Component({
+ selector: "texera-bp-wf-preview",
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+ Couldn't load workflow preview · {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ truncate(n.label) }}
+
+
+
+
+
+
+ Loading workflow…
+
+
+ `,
+ styles: [
+ `
+ :host {
+ display: block;
+ width: 560px;
+ background: var(--bp-popover);
+ border: 1px solid var(--bp-line);
+ border-radius: 10px;
+ padding: 12px 14px;
+ box-shadow: var(--bp-shadow);
+ }
+ .wf-preview-header {
+ color: var(--bp-muted);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+ .wf-preview-msg {
+ color: var(--bp-muted);
+ font-size: 12.5px;
+ padding: 6px 2px;
+ }
+ .wf-svg {
+ width: 100%;
+ height: 260px;
+ display: block;
+ }
+ `,
+ ],
+})
+export class BpWfPreviewComponent implements OnChanges, OnDestroy {
+ private readonly destroy$ = new Subject();
+ @Input() wid: number | null = null;
+ loaded = false;
+ error = "";
+ nodes: Node[] = [];
+ edgePaths: string[] = [];
+ opCount = 0;
+ linkCount = 0;
+ readonly VIEW_W = VIEW_W;
+ readonly VIEW_H = VIEW_H;
+
+ constructor(private http: HttpClient) {}
+
+ ngOnChanges(): void {
+ if (!this.wid) return;
+ if (CACHE.has(this.wid)) {
+ this.render(CACHE.get(this.wid)!);
+ return;
+ }
+ this.loaded = false;
+ this.error = "";
+ this.http
+ .get(`/api/workflow/${this.wid}`)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: resp => {
+ try {
+ const raw = resp?.content ?? resp?.workflow?.content;
+ const parsed: WfContent = typeof raw === "string" ? JSON.parse(raw) : raw;
+ CACHE.set(this.wid!, parsed);
+ this.render(parsed);
+ } catch (e) {
+ this.error = "parse error";
+ }
+ },
+ error: () => {
+ this.error = "fetch failed";
+ },
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private render(c: WfContent): void {
+ const ops = c.operators || [];
+ const links = c.links || [];
+ const positions = c.operatorPositions || {};
+
+ this.opCount = ops.length;
+ this.linkCount = links.length;
+
+ // Original coordinate bounds
+ const xs = ops.map(o => positions[o.operatorID]?.x ?? 0);
+ const ys = ops.map(o => positions[o.operatorID]?.y ?? 0);
+ const minX = Math.min(...xs, 0);
+ const minY = Math.min(...ys, 0);
+ const maxX = Math.max(...xs, 1) + NODE_W;
+ const maxY = Math.max(...ys, 1) + NODE_H;
+
+ const pad = 12;
+ const sx = (VIEW_W - 2 * pad) / Math.max(1, maxX - minX);
+ const sy = (VIEW_H - 2 * pad) / Math.max(1, maxY - minY);
+ const s = Math.min(sx, sy, 1);
+
+ const indexById = new Map();
+ this.nodes = ops.map(o => {
+ const pos = positions[o.operatorID] ?? { x: 0, y: 0 };
+ const n: Node = {
+ id: o.operatorID,
+ label: o.customDisplayName || o.operatorID,
+ x: pad + (pos.x - minX) * s,
+ y: pad + (pos.y - minY) * s,
+ w: NODE_W * s,
+ h: NODE_H * s,
+ kind: this.classify(o, links),
+ };
+ indexById.set(o.operatorID, n);
+ return n;
+ });
+
+ this.edgePaths = links
+ .map(l => {
+ const a = indexById.get(l.source.operatorID);
+ const b = indexById.get(l.target.operatorID);
+ if (!a || !b) return "";
+ const x1 = a.x + a.w;
+ const y1 = a.y + a.h / 2;
+ const x2 = b.x;
+ const y2 = b.y + b.h / 2;
+ const dx = Math.max(20, (x2 - x1) / 2);
+ return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
+ })
+ .filter(p => p !== "");
+
+ this.loaded = true;
+ }
+
+ private classify(op: WfOp, links: WfLink[]): "source" | "udf" | "sink" {
+ const id = op.operatorID;
+ const hasIn = links.some(l => l.target.operatorID === id);
+ const hasOut = links.some(l => l.source.operatorID === id);
+ if (!hasIn) return "source";
+ if (!hasOut) return "sink";
+ return "udf";
+ }
+
+ nodeFill(kind: Node["kind"]): string {
+ if (kind === "source") return "var(--bp-panel-2)";
+ if (kind === "sink") return "rgba(74, 222, 128, 0.10)";
+ return "var(--bp-panel)";
+ }
+ nodeStroke(kind: Node["kind"]): string {
+ if (kind === "source") return "var(--bp-line)";
+ if (kind === "sink") return "var(--bp-good)";
+ return "var(--bp-accent)";
+ }
+
+ truncate(s: string): string {
+ if (s.length <= 14) return s;
+ return s.slice(0, 13) + "…";
+ }
+}
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 5f4a00952a9..4ea3b780b61 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -413,3 +413,133 @@ hr {
}
}
}
+
+// =========================================================================
+// Global dark theme. Applied when .
+// Best-effort wash over Texera + ng-zorro. Not a full design pass.
+// =========================================================================
+html[data-app-theme="dark"] {
+ --app-bg: #0b0d12;
+ --app-bg-elev: #14171f;
+ --app-bg-2: #1a1e28;
+ --app-line: #262b39;
+ --app-text: #ecf0f8;
+ --app-text-2: #cdd3e0;
+ --app-muted: #8b93a7;
+ --app-accent: #ff4655;
+
+ background: var(--app-bg);
+ color: var(--app-text);
+
+ body,
+ .ant-layout,
+ .ant-layout-content,
+ nz-content,
+ .page-container,
+ .page-content-layout {
+ background: var(--app-bg) !important;
+ color: var(--app-text) !important;
+ }
+ .ant-layout-sider,
+ nz-sider,
+ #left-sider {
+ background: var(--app-bg-elev) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-menu,
+ .ant-menu-submenu > .ant-menu,
+ .ant-menu-inline,
+ .ant-menu-vertical,
+ .ant-menu-light {
+ background: var(--app-bg-elev) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-menu-item,
+ .ant-menu-submenu-title {
+ color: var(--app-text-2) !important;
+ }
+ .ant-menu-item:hover,
+ .ant-menu-submenu-title:hover {
+ color: var(--app-text) !important;
+ background: var(--app-bg-2) !important;
+ }
+ .ant-menu-item-selected,
+ .ant-menu-item-active {
+ background: rgba(255, 70, 85, 0.1) !important;
+ color: var(--app-accent) !important;
+ }
+ .ant-menu-submenu-arrow::before,
+ .ant-menu-submenu-arrow::after {
+ background: var(--app-muted) !important;
+ }
+ .ant-card,
+ .ant-modal-content,
+ .ant-modal-header,
+ .ant-dropdown-menu,
+ .ant-popover-inner,
+ .ant-select-dropdown,
+ .ant-table {
+ background: var(--app-bg-elev) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-card-head,
+ .ant-table-thead > tr > th,
+ .ant-table-tbody > tr > td {
+ background: var(--app-bg-elev) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-table-tbody > tr:hover > td {
+ background: var(--app-bg-2) !important;
+ }
+ .ant-input,
+ .ant-input-affix-wrapper,
+ .ant-select-selector,
+ .ant-picker {
+ background: var(--app-bg-2) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-input::placeholder {
+ color: var(--app-muted) !important;
+ }
+ .ant-btn-default {
+ background: var(--app-bg-2) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ .ant-btn-default:hover {
+ border-color: var(--app-text) !important;
+ }
+ .ant-tabs-tab,
+ .ant-tabs-tab-btn {
+ color: var(--app-text-2) !important;
+ }
+ .ant-tabs-tab-active .ant-tabs-tab-btn {
+ color: var(--app-accent) !important;
+ }
+ .ant-tabs-ink-bar {
+ background: var(--app-accent) !important;
+ }
+ .ant-divider {
+ border-color: var(--app-line) !important;
+ }
+ .ant-tooltip-inner {
+ background: #050608 !important;
+ color: var(--app-text) !important;
+ }
+ #nav,
+ .nav-bar {
+ background: var(--app-bg-elev) !important;
+ color: var(--app-text) !important;
+ border-color: var(--app-line) !important;
+ }
+ hr,
+ .border-top,
+ .border-bottom {
+ border-color: var(--app-line) !important;
+ }
+}