Spaces:
Running
Running
Merge remote-tracking branch 'hf-test/main' into claude/custom-mcp-spotter-demo-125da8
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +13 -0
- CLAUDE.md +65 -4
- Dockerfile +13 -1
- chat_interface.py +60 -10
- demoprep_app/dataset/engine.py +159 -45
- demoprep_app/dataset/formula.py +456 -0
- demoprep_app/dataset/quality.py +66 -0
- demoprep_app/dataset/validator.py +20 -4
- demoprep_app/integrations/snowflake/__init__.py +11 -2
- demoprep_app/integrations/snowflake/quality_gate.py +620 -64
- demoprep_app/liveboards/blueprint.py +41 -5
- demoprep_app/liveboards/golden_layout.py +783 -56
- demoprep_app/liveboards/muze_templates.py +335 -0
- demoprep_app/liveboards/tml_yaml.py +41 -0
- demoprep_app/pipeline/build_demo.py +66 -3
- demoprep_app/portal/README.md +55 -0
- demoprep_app/portal/assets/triumph-logo.svg +1 -0
- demoprep_app/portal/assets/vantage-logo.svg +1 -0
- demoprep_app/portal/builder.py +565 -0
- demoprep_app/portal/references/SKILL.md +186 -0
- demoprep_app/portal/references/build-playbook.md +154 -0
- demoprep_app/portal/references/intake.md +52 -0
- demoprep_app/portal/references/spec-schema.md +79 -0
- demoprep_app/portal/references/spec.example.json +312 -0
- demoprep_app/portal/scripts/apply-spec.mjs +294 -0
- demoprep_app/portal/specs/triumph.json +239 -0
- demoprep_app/portal/specs/vantage.json +238 -0
- demoprep_app/portal/template-tse/.gitignore +20 -0
- demoprep_app/portal/template-tse/README.md +44 -0
- demoprep_app/portal/template-tse/index.html +19 -0
- demoprep_app/portal/template-tse/package-lock.json +1934 -0
- demoprep_app/portal/template-tse/package.json +26 -0
- demoprep_app/portal/template-tse/public/northwind-icon.svg +4 -0
- demoprep_app/portal/template-tse/public/skyscanner-icon.svg +13 -0
- demoprep_app/portal/template-tse/src/App.tsx +60 -0
- demoprep_app/portal/template-tse/src/assets/northwind-logo.svg +5 -0
- demoprep_app/portal/template-tse/src/components/BidModal.tsx +133 -0
- demoprep_app/portal/template-tse/src/components/ChatBot.tsx +186 -0
- demoprep_app/portal/template-tse/src/components/ColumnFilter.tsx +149 -0
- demoprep_app/portal/template-tse/src/components/CreateDashboardModal.tsx +108 -0
- demoprep_app/portal/template-tse/src/components/DateRangeFilter.tsx +155 -0
- demoprep_app/portal/template-tse/src/components/NorthwindLogo.tsx +25 -0
- demoprep_app/portal/template-tse/src/components/PinModal.tsx +81 -0
- demoprep_app/portal/template-tse/src/components/RepFilter.tsx +139 -0
- demoprep_app/portal/template-tse/src/components/RepHierarchyFilter.tsx +268 -0
- demoprep_app/portal/template-tse/src/components/TopBar.tsx +148 -0
- demoprep_app/portal/template-tse/src/components/TrialModal.tsx +67 -0
- demoprep_app/portal/template-tse/src/components/WinBackModal.tsx +158 -0
- demoprep_app/portal/template-tse/src/config.ts +509 -0
- demoprep_app/portal/template-tse/src/content.ts +111 -0
.gitignore
CHANGED
|
@@ -228,3 +228,16 @@ scratch/
|
|
| 228 |
legitdata_project/venv/
|
| 229 |
legitdata_project/*.egg-info/
|
| 230 |
legitdata_project/.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
legitdata_project/venv/
|
| 229 |
legitdata_project/*.egg-info/
|
| 230 |
legitdata_project/.DS_Store
|
| 231 |
+
|
| 232 |
+
# Portal builder (demoprep_app/portal) — vendored template + specs must be tracked.
|
| 233 |
+
# Overrides the broad *.json / lib/ / dist rules above for this directory only.
|
| 234 |
+
!demoprep_app/portal/**/*.json
|
| 235 |
+
!demoprep_app/portal/template-tse/src/lib/
|
| 236 |
+
!demoprep_app/portal/template-tse/src/lib/**
|
| 237 |
+
demoprep_app/portal/template-tse/node_modules/
|
| 238 |
+
demoprep_app/portal/template-tse/dist/
|
| 239 |
+
# Generated portal apps (codemod output) stay out of the repo
|
| 240 |
+
/*-tse/
|
| 241 |
+
|
| 242 |
+
# Portal build working dir (scripts/build_portal.py output)
|
| 243 |
+
/build/
|
CLAUDE.md
CHANGED
|
@@ -85,6 +85,37 @@ For overall project status, see `PROJECT_STATUS.md` in root.
|
|
| 85 |
| `main` | Production — always stable and shippable | `hf` (prod) |
|
| 86 |
| `develop` | Sprint integration — current sprint work | `hf-test` |
|
| 87 |
| `feature/*` | Individual features — branch off `develop` | local / `hf-test` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
### Rules
|
| 90 |
|
|
@@ -117,8 +148,38 @@ git push hf-test develop:main
|
|
| 117 |
git push origin main develop
|
| 118 |
```
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
> **Never push sprint/feature work directly to `main`.
|
| 121 |
-
> Never push to `hf` (prod) from anything other than `main`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
### Sprint Doc Workflow - IMPORTANT
|
| 124 |
|
|
@@ -209,7 +270,7 @@ Example:
|
|
| 209 |
## Key Project Facts
|
| 210 |
|
| 211 |
### Environment Setup
|
| 212 |
-
- **Python**: Using virtual environment at `./
|
| 213 |
- **NOT using conda** - if you don't see packages, activate the venv first
|
| 214 |
- **Supabase**: IS installed and configured - credentials in .env work fine
|
| 215 |
- **ThoughtSpot**: Trusted auth via per-environment key (selected from TS Environment dropdown). No global admin user — objects are created under the logged-in user's email.
|
|
@@ -218,7 +279,7 @@ Example:
|
|
| 218 |
|
| 219 |
```bash
|
| 220 |
# Run the app (chat_interface.py IS the full app — App tab, Chat tab, Admin, all tabs)
|
| 221 |
-
source ./
|
| 222 |
|
| 223 |
# Check git changes
|
| 224 |
git diff --stat
|
|
@@ -232,7 +293,7 @@ lsof -i :7860
|
|
| 232 |
2. **DO NOT** try to install supabase or other packages - they're already in the venv
|
| 233 |
3. **DO NOT** change default values to "thoughtspot.com" - that's for customer URLs, not ThoughtSpot's
|
| 234 |
4. **DO NOT** assume worksheets are needed - they're deprecated, we use models now
|
| 235 |
-
5. **ALWAYS** use the venv when running Python: `source ./
|
| 236 |
|
| 237 |
### Working Patterns
|
| 238 |
- Settings save/load through Supabase - this WORKS when using venv
|
|
|
|
| 85 |
| `main` | Production — always stable and shippable | `hf` (prod) |
|
| 86 |
| `develop` | Sprint integration — current sprint work | `hf-test` |
|
| 87 |
| `feature/*` | Individual features — branch off `develop` | local / `hf-test` |
|
| 88 |
+
| `deploy/mcp` | MCP Space integration — shares history via `develop` merges | `hf-mcp-test`, then `hf-mcp` (prod) |
|
| 89 |
+
|
| 90 |
+
### Remotes
|
| 91 |
+
|
| 92 |
+
| Remote | Space | Tier |
|
| 93 |
+
|--------|-------|------|
|
| 94 |
+
| `hf` | `thoughtspot-dp/demoprep` | app — **PROD** |
|
| 95 |
+
| `hf-test` | `thoughtspot-dp/test-demoprep` | app — test |
|
| 96 |
+
| `hf-mcp` | `thoughtspot-demoprep/mcp` | MCP — **PROD** |
|
| 97 |
+
| `hf-mcp-test` | `thoughtspot-demoprep/test-mcp` | MCP — test |
|
| 98 |
+
|
| 99 |
+
### ⚠️ Reality check (verified 2026-08-26) — the model below is ASPIRATIONAL
|
| 100 |
+
|
| 101 |
+
The documented flow and the deployed truth have drifted. Verify before trusting:
|
| 102 |
+
|
| 103 |
+
| Claim in this doc | Actual state on 2026-08-26 |
|
| 104 |
+
|---|---|
|
| 105 |
+
| `develop` → `hf-test` | **False.** `hf-test/main` == `spike/portal-template`. A spike branch is the de facto integration branch. |
|
| 106 |
+
| `develop` is sprint integration | **Stale.** `develop` (tip `8c598cc`) is missing the current CLAUDE.md and the whole "Data Quality Push" sprint section. |
|
| 107 |
+
| Space remotes only receive branch pushes | **False.** `hf`/`hf-mcp` accept direct pushes and routinely hold commits no local branch has (that is how `4b87c36` came to exist only on the Space). |
|
| 108 |
+
|
| 109 |
+
**Always confirm what is actually deployed before reasoning about it:**
|
| 110 |
+
```bash
|
| 111 |
+
git fetch --all
|
| 112 |
+
git log --oneline -1 hf-mcp/main # what PROD MCP is really running
|
| 113 |
+
git branch --all --contains <sha> # who has a given fix
|
| 114 |
+
```
|
| 115 |
+
Deployed sha is also visible per-Space via `HfApi().space_info(repo_id).sha`.
|
| 116 |
+
|
| 117 |
+
**Do not "fix" this drift by rewriting the model here** — decide the intended
|
| 118 |
+
model with boone first, then make doc and reality match in one deliberate pass.
|
| 119 |
|
| 120 |
### Rules
|
| 121 |
|
|
|
|
| 148 |
git push origin main develop
|
| 149 |
```
|
| 150 |
|
| 151 |
+
**MCP Space deploys (`deploy/mcp`) — test tier is MANDATORY:**
|
| 152 |
+
```bash
|
| 153 |
+
git checkout deploy/mcp
|
| 154 |
+
git merge develop # pick up sprint work
|
| 155 |
+
# ... MCP-specific work ...
|
| 156 |
+
git push hf-mcp-test deploy/mcp:main # 1. deploy to TEST first — ALWAYS
|
| 157 |
+
# verify (see smoke check below), THEN:
|
| 158 |
+
git push hf-mcp deploy/mcp:main # 2. deploy to PROD
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
**MCP post-deploy smoke check — a green build log is NOT proof:**
|
| 162 |
+
```bash
|
| 163 |
+
curl -s https://thoughtspot-demoprep-test-mcp.hf.space/
|
| 164 |
+
```
|
| 165 |
+
Expect `{"status":"ok"}`. The health endpoint is unauthenticated, but it only
|
| 166 |
+
proves the container booted — it does **not** exercise a build. Also call the
|
| 167 |
+
`ping` tool, and run one real `build_demo_from_brief` against test before prod.
|
| 168 |
+
|
| 169 |
+
> **The 2026-08-25 outage happened because logging code that only the MCP path
|
| 170 |
+
> constructs (`PromptLogger`) raised `PermissionError: '/app/logs'` under the
|
| 171 |
+
> Space's non-root user — every MCP build returned `failed`, while `GET /` still
|
| 172 |
+
> answered `ok`. HF Spaces run the container as a non-root user inside a
|
| 173 |
+
> root-owned WORKDIR, and `logs/` is gitignored so it is absent from the image.
|
| 174 |
+
> Anything that writes to disk at runtime must resolve a writable dir or degrade.**
|
| 175 |
+
|
| 176 |
> **Never push sprint/feature work directly to `main`.
|
| 177 |
+
> Never push to `hf` (prod) from anything other than `main`.
|
| 178 |
+
> Never push to `hf-mcp` (prod) without deploying to `hf-mcp-test` first.
|
| 179 |
+
> Never leave a hotfix living only on a Space remote** — a fix pushed straight to
|
| 180 |
+
> `hf-mcp`/`hf` that is not merged back into `deploy/mcp`, `develop`, AND `main`
|
| 181 |
+
> will be silently reverted by the next routine deploy. After ANY Space hotfix,
|
| 182 |
+
> run `git branch --all --contains <sha>` and confirm every deploy branch lists it.
|
| 183 |
|
| 184 |
### Sprint Doc Workflow - IMPORTANT
|
| 185 |
|
|
|
|
| 270 |
## Key Project Facts
|
| 271 |
|
| 272 |
### Environment Setup
|
| 273 |
+
- **Python**: Using virtual environment at `./.venv/bin/activate`
|
| 274 |
- **NOT using conda** - if you don't see packages, activate the venv first
|
| 275 |
- **Supabase**: IS installed and configured - credentials in .env work fine
|
| 276 |
- **ThoughtSpot**: Trusted auth via per-environment key (selected from TS Environment dropdown). No global admin user — objects are created under the logged-in user's email.
|
|
|
|
| 279 |
|
| 280 |
```bash
|
| 281 |
# Run the app (chat_interface.py IS the full app — App tab, Chat tab, Admin, all tabs)
|
| 282 |
+
source ./.venv/bin/activate && python chat_interface.py
|
| 283 |
|
| 284 |
# Check git changes
|
| 285 |
git diff --stat
|
|
|
|
| 293 |
2. **DO NOT** try to install supabase or other packages - they're already in the venv
|
| 294 |
3. **DO NOT** change default values to "thoughtspot.com" - that's for customer URLs, not ThoughtSpot's
|
| 295 |
4. **DO NOT** assume worksheets are needed - they're deprecated, we use models now
|
| 296 |
+
5. **ALWAYS** use the venv when running Python: `source ./.venv/bin/activate && python`
|
| 297 |
|
| 298 |
### Working Patterns
|
| 299 |
- Settings save/load through Supabase - this WORKS when using venv
|
Dockerfile
CHANGED
|
@@ -12,13 +12,25 @@ FROM python:3.11-slim
|
|
| 12 |
RUN useradd -m -u 1000 user
|
| 13 |
WORKDIR /app
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
COPY --chown=user requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
|
| 20 |
COPY --chown=user . .
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
USER user
|
| 23 |
ENV MCP_TRANSPORT=http \
|
| 24 |
MCP_HTTP_PORT=7860 \
|
|
|
|
| 12 |
RUN useradd -m -u 1000 user
|
| 13 |
WORKDIR /app
|
| 14 |
|
| 15 |
+
# node + npm are required by the build_portal tool: the portal codemod is a Node
|
| 16 |
+
# script and the site is built with vite. Debian bookworm ships Node 18, which
|
| 17 |
+
# satisfies vite 5 (^18 || >=20).
|
| 18 |
+
RUN apt-get update && apt-get install -y git curl nodejs npm \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
|
| 21 |
COPY --chown=user requirements.txt .
|
| 22 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
|
| 24 |
COPY --chown=user . .
|
| 25 |
|
| 26 |
+
# Pre-install the portal template's node_modules AS ROOT, at image build time.
|
| 27 |
+
# The codemod installs them on first use if absent — but the runtime user cannot
|
| 28 |
+
# write into /app, so that would fail exactly like the 2026-08-25 PromptLogger
|
| 29 |
+
# outage. Installing here means every portal build finds them ready.
|
| 30 |
+
RUN cd demoprep_app/portal/template-tse \
|
| 31 |
+
&& npm install --no-audit --no-fund --loglevel=error \
|
| 32 |
+
&& chown -R user:user node_modules
|
| 33 |
+
|
| 34 |
USER user
|
| 35 |
ENV MCP_TRANSPORT=http \
|
| 36 |
MCP_HTTP_PORT=7860 \
|
chat_interface.py
CHANGED
|
@@ -395,6 +395,7 @@ class ChatDemoInterface:
|
|
| 395 |
self._session_logger = None
|
| 396 |
self._prompt_logger = None
|
| 397 |
self._demo_bundle = None
|
|
|
|
| 398 |
|
| 399 |
def clear_deployment_completion(self) -> None:
|
| 400 |
self.deployment_completion = None
|
|
@@ -3165,6 +3166,7 @@ To change settings, use:
|
|
| 3165 |
schema_name = f"DM{yymmdd}_{hhmmss}_{company_clean}_{usecase_clean}"
|
| 3166 |
|
| 3167 |
self._demo_bundle = None
|
|
|
|
| 3168 |
from demoprep_app.pipeline.build_demo import build_demo
|
| 3169 |
|
| 3170 |
row_count_guidance = int(self.settings.get("fact_table_size", 5000) or 5000)
|
|
@@ -3194,6 +3196,10 @@ To change settings, use:
|
|
| 3194 |
"Check the warnings above and retry."
|
| 3195 |
)
|
| 3196 |
self._demo_bundle = build.dataset
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3197 |
self.demo_builder.schema_generation_results = build.ddl
|
| 3198 |
self.ddl_code = build.ddl
|
| 3199 |
for warning in build.warnings:
|
|
@@ -3959,22 +3965,34 @@ LegitData will generate realistic, AI-powered data.
|
|
| 3959 |
log_progress(f"[OK] {pop_message}")
|
| 3960 |
|
| 3961 |
# Data-quality gate: after every load, profile the MEASURE columns
|
| 3962 |
-
# that were actually written to Snowflake
|
| 3963 |
-
#
|
| 3964 |
-
#
|
|
|
|
|
|
|
| 3965 |
from demoprep_app.integrations.snowflake import (
|
| 3966 |
-
|
|
|
|
|
|
|
| 3967 |
run_measure_quality_gate,
|
| 3968 |
)
|
| 3969 |
progress += f"\n\n**Verifying data quality (measure columns)...**"
|
| 3970 |
yield progress
|
| 3971 |
log_progress("")
|
| 3972 |
log_progress("Running data-quality gate on measure columns...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3973 |
try:
|
| 3974 |
dq_profile = run_measure_quality_gate(
|
| 3975 |
-
deployer.connection,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3976 |
)
|
| 3977 |
-
except
|
| 3978 |
self._last_population_error = str(e)
|
| 3979 |
self._last_schema_name = schema_name
|
| 3980 |
_deploy_meta = {
|
|
@@ -3991,20 +4009,46 @@ LegitData will generate realistic, AI-powered data.
|
|
| 3991 |
raise Exception(str(e))
|
| 3992 |
|
| 3993 |
dq_warnings = dq_profile.get("warnings", [])
|
|
|
|
|
|
|
| 3994 |
self._dq_warnings = dq_warnings
|
| 3995 |
progress += (
|
| 3996 |
-
f"\n[OK] Data quality gate
|
| 3997 |
-
f"
|
| 3998 |
-
f"across {dq_profile['tables_checked']} tables
|
| 3999 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4000 |
for _dq_w in dq_warnings:
|
| 4001 |
progress += f"\n[WARN] {_dq_w}"
|
| 4002 |
if _slog:
|
| 4003 |
_slog.log_verbose(
|
| 4004 |
-
"deploy", "data quality gate
|
| 4005 |
measure_columns_checked=dq_profile["measure_columns_checked"],
|
| 4006 |
tables_checked=dq_profile["tables_checked"],
|
| 4007 |
dq_warnings=dq_warnings,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4008 |
)
|
| 4009 |
|
| 4010 |
self._deployed_schema_name = schema_name
|
|
@@ -4017,6 +4061,12 @@ LegitData will generate realistic, AI-powered data.
|
|
| 4017 |
"measure_columns_checked": dq_profile["measure_columns_checked"],
|
| 4018 |
"tables_checked": dq_profile["tables_checked"],
|
| 4019 |
"warnings": dq_warnings,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4020 |
},
|
| 4021 |
}
|
| 4022 |
|
|
|
|
| 395 |
self._session_logger = None
|
| 396 |
self._prompt_logger = None
|
| 397 |
self._demo_bundle = None
|
| 398 |
+
self._demo_blueprint = None
|
| 399 |
|
| 400 |
def clear_deployment_completion(self) -> None:
|
| 401 |
self.deployment_completion = None
|
|
|
|
| 3166 |
schema_name = f"DM{yymmdd}_{hhmmss}_{company_clean}_{usecase_clean}"
|
| 3167 |
|
| 3168 |
self._demo_bundle = None
|
| 3169 |
+
self._demo_blueprint = None
|
| 3170 |
from demoprep_app.pipeline.build_demo import build_demo
|
| 3171 |
|
| 3172 |
row_count_guidance = int(self.settings.get("fact_table_size", 5000) or 5000)
|
|
|
|
| 3196 |
"Check the warnings above and retry."
|
| 3197 |
)
|
| 3198 |
self._demo_bundle = build.dataset
|
| 3199 |
+
# The blueprint is what the post-load quality gate needs: the
|
| 3200 |
+
# formulas it can recompute a bad column from, and the measures the
|
| 3201 |
+
# demo's story actually depends on.
|
| 3202 |
+
self._demo_blueprint = build.blueprint
|
| 3203 |
self.demo_builder.schema_generation_results = build.ddl
|
| 3204 |
self.ddl_code = build.ddl
|
| 3205 |
for warning in build.warnings:
|
|
|
|
| 3965 |
log_progress(f"[OK] {pop_message}")
|
| 3966 |
|
| 3967 |
# Data-quality gate: after every load, profile the MEASURE columns
|
| 3968 |
+
# that were actually written to Snowflake, then REPAIR what it can
|
| 3969 |
+
# (recompute a derived column from its formula, clamp a part to its
|
| 3970 |
+
# whole, binarize a flag) and OMIT what it cannot. It aborts the run
|
| 3971 |
+
# only when there is nothing demo-able left — a board with one
|
| 3972 |
+
# missing tile beats an empty schema.
|
| 3973 |
from demoprep_app.integrations.snowflake import (
|
| 3974 |
+
MeasureQualityGateError,
|
| 3975 |
+
apply_measure_omissions,
|
| 3976 |
+
measure_formulas,
|
| 3977 |
run_measure_quality_gate,
|
| 3978 |
)
|
| 3979 |
progress += f"\n\n**Verifying data quality (measure columns)...**"
|
| 3980 |
yield progress
|
| 3981 |
log_progress("")
|
| 3982 |
log_progress("Running data-quality gate on measure columns...")
|
| 3983 |
+
_dq_formulas = measure_formulas(self._demo_bundle) if self._demo_bundle else {}
|
| 3984 |
+
_dq_required = sorted({
|
| 3985 |
+
i.measure for i in getattr(self._demo_blueprint, "insights", []) or [] if i.measure
|
| 3986 |
+
})
|
| 3987 |
try:
|
| 3988 |
dq_profile = run_measure_quality_gate(
|
| 3989 |
+
deployer.connection,
|
| 3990 |
+
schema_name,
|
| 3991 |
+
progress_callback=log_progress,
|
| 3992 |
+
formulas=_dq_formulas,
|
| 3993 |
+
required_measures=_dq_required,
|
| 3994 |
)
|
| 3995 |
+
except MeasureQualityGateError as e:
|
| 3996 |
self._last_population_error = str(e)
|
| 3997 |
self._last_schema_name = schema_name
|
| 3998 |
_deploy_meta = {
|
|
|
|
| 4009 |
raise Exception(str(e))
|
| 4010 |
|
| 4011 |
dq_warnings = dq_profile.get("warnings", [])
|
| 4012 |
+
dq_repairs = dq_profile.get("repairs", [])
|
| 4013 |
+
dq_omissions = dq_profile.get("omissions", [])
|
| 4014 |
self._dq_warnings = dq_warnings
|
| 4015 |
progress += (
|
| 4016 |
+
f"\n[OK] Data quality gate: "
|
| 4017 |
+
f"{dq_profile['measure_columns_checked']} measure columns "
|
| 4018 |
+
f"across {dq_profile['tables_checked']} tables"
|
| 4019 |
)
|
| 4020 |
+
for _dq_r in dq_repairs:
|
| 4021 |
+
progress += f"\n[REPAIR] {_dq_r}"
|
| 4022 |
+
if dq_omissions:
|
| 4023 |
+
# A column dropped in Snowflake has to disappear from the DDL
|
| 4024 |
+
# too: the ThoughtSpot model and liveboard are built from it
|
| 4025 |
+
# later in the run and would otherwise declare a column that no
|
| 4026 |
+
# longer exists.
|
| 4027 |
+
_removed = apply_measure_omissions(self._demo_bundle, dq_omissions)
|
| 4028 |
+
if _removed:
|
| 4029 |
+
from demoprep_app.ddl import DatasetDdlCompiler
|
| 4030 |
+
_new_ddl = DatasetDdlCompiler().compile(self._demo_bundle)
|
| 4031 |
+
self.ddl_code = _new_ddl
|
| 4032 |
+
self.demo_builder.schema_generation_results = _new_ddl
|
| 4033 |
+
for _dq_o in dq_omissions:
|
| 4034 |
+
progress += (
|
| 4035 |
+
f"\n[OMITTED] {_dq_o['table']}.{_dq_o['column']} "
|
| 4036 |
+
f"({_dq_o['defect']}: {_dq_o['detail']})"
|
| 4037 |
+
)
|
| 4038 |
for _dq_w in dq_warnings:
|
| 4039 |
progress += f"\n[WARN] {_dq_w}"
|
| 4040 |
if _slog:
|
| 4041 |
_slog.log_verbose(
|
| 4042 |
+
"deploy", "data quality gate complete",
|
| 4043 |
measure_columns_checked=dq_profile["measure_columns_checked"],
|
| 4044 |
tables_checked=dq_profile["tables_checked"],
|
| 4045 |
dq_warnings=dq_warnings,
|
| 4046 |
+
dq_repairs=dq_repairs,
|
| 4047 |
+
dq_omissions=[f"{o['table']}.{o['column']}" for o in dq_omissions],
|
| 4048 |
+
dq_defects=[
|
| 4049 |
+
f"{d['table']}.{d['column']}:{d['defect']}"
|
| 4050 |
+
for d in dq_profile.get("defects", [])
|
| 4051 |
+
],
|
| 4052 |
)
|
| 4053 |
|
| 4054 |
self._deployed_schema_name = schema_name
|
|
|
|
| 4061 |
"measure_columns_checked": dq_profile["measure_columns_checked"],
|
| 4062 |
"tables_checked": dq_profile["tables_checked"],
|
| 4063 |
"warnings": dq_warnings,
|
| 4064 |
+
"repairs": dq_repairs,
|
| 4065 |
+
"omissions": [f"{o['table']}.{o['column']}" for o in dq_omissions],
|
| 4066 |
+
"defects": [
|
| 4067 |
+
f"{d['table']}.{d['column']}:{d['defect']}"
|
| 4068 |
+
for d in dq_profile.get("defects", [])
|
| 4069 |
+
],
|
| 4070 |
},
|
| 4071 |
}
|
| 4072 |
|
demoprep_app/dataset/engine.py
CHANGED
|
@@ -15,12 +15,15 @@ from __future__ import annotations
|
|
| 15 |
|
| 16 |
import ast
|
| 17 |
import math
|
| 18 |
-
import operator
|
| 19 |
import random
|
| 20 |
from datetime import date, timedelta
|
| 21 |
from typing import Any
|
| 22 |
|
| 23 |
from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from demoprep_app.scenario.blueprint import (
|
| 25 |
BlueprintDimension,
|
| 26 |
BlueprintMeasure,
|
|
@@ -46,7 +49,22 @@ def _safe_date(value):
|
|
| 46 |
class BlueprintEngine:
|
| 47 |
"""Deterministic executor for DemoBlueprint -> DatasetBundle."""
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def generate(self, blueprint: DemoBlueprint, row_count: int | None = None) -> DatasetBundle:
|
|
|
|
| 50 |
rng = random.Random(blueprint.seed or 1)
|
| 51 |
total_target = max(500, int(row_count or 5000))
|
| 52 |
per_fact = max(500, total_target // max(1, len(blueprint.facts)))
|
|
@@ -198,7 +216,27 @@ class BlueprintEngine:
|
|
| 198 |
# Never clamp a derived value: a negative margin / ending
|
| 199 |
# inventory is legitimate and must still satisfy its formula
|
| 200 |
# (the validator re-checks it against the stored values).
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
continue
|
| 203 |
if m.share_of:
|
| 204 |
# "part of a whole": a fraction of the parent's value. The parent
|
|
@@ -209,8 +247,36 @@ class BlueprintEngine:
|
|
| 209 |
low = m.params.get("low", 0.05)
|
| 210 |
high = m.params.get("high", 0.85)
|
| 211 |
imult = _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d)
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
continue
|
| 215 |
if m.per and f"{m.per}_KEY" in row:
|
| 216 |
# Stable characteristic of an entity: one level per member,
|
|
@@ -227,10 +293,20 @@ class BlueprintEngine:
|
|
| 227 |
mult *= _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d)
|
| 228 |
# Format base values NOW so derived formulas — and the validator,
|
| 229 |
# which re-checks formulas against stored values — see identical numbers.
|
| 230 |
-
values[m.name] = _format_value(
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
for m in fact.measures:
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
rows.append(row)
|
| 235 |
|
| 236 |
return DatasetTable(fact.name, fact.grain, columns, rows, is_fact=True)
|
|
@@ -266,6 +342,12 @@ def _trend_multiplier(pct_per_year: float, start: date, current: date) -> float:
|
|
| 266 |
return (1.0 + pct_per_year / 100.0) ** years_elapsed
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
def _sample(measure: BlueprintMeasure, rng: random.Random) -> float:
|
| 270 |
p = measure.params
|
| 271 |
if measure.distribution == "uniform":
|
|
@@ -273,6 +355,12 @@ def _sample(measure: BlueprintMeasure, rng: random.Random) -> float:
|
|
| 273 |
mean = p.get("mean", 1.0)
|
| 274 |
sigma = p.get("sigma", 0.4)
|
| 275 |
if measure.distribution == "normal":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
return max(0.0, rng.gauss(mean, abs(sigma) if sigma > 1 else mean * abs(sigma)))
|
| 277 |
# lognormal: mean is the desired arithmetic mean, sigma the log-space spread
|
| 278 |
sigma = min(max(abs(sigma), 0.05), 1.5)
|
|
@@ -298,55 +386,79 @@ def _insight_multiplier(entries, picked: dict[str, str], d: date) -> float:
|
|
| 298 |
return mult
|
| 299 |
|
| 300 |
|
| 301 |
-
def _format_value(value: float, fmt: str, clamp: bool = True
|
|
|
|
| 302 |
"""Round a value for storage. clamp bounds sampled base measures to a sane
|
| 303 |
-
range (no negative units, pct
|
| 304 |
-
so their stored value stays faithful to the formula the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
if fmt == "int":
|
| 306 |
v = int(round(value))
|
| 307 |
-
return max(0, v) if clamp else v
|
| 308 |
if fmt in ("currency", "float"):
|
| 309 |
return round(value, 2)
|
| 310 |
if fmt == "pct":
|
| 311 |
if clamp:
|
| 312 |
-
value = min(max(value, 0.0),
|
| 313 |
-
return round(value, 2)
|
| 314 |
return round(value, 2)
|
| 315 |
|
| 316 |
|
| 317 |
-
def
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
|
| 332 |
-
def
|
| 333 |
-
|
| 334 |
-
tree = ast.parse(formula, mode="eval")
|
| 335 |
-
return float(_eval_node(tree.body, values))
|
| 336 |
-
except Exception:
|
| 337 |
-
return 0.0
|
| 338 |
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
return _OPS[type(node.op)](_eval_node(node.operand, values))
|
| 345 |
-
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
| 346 |
-
return float(node.value)
|
| 347 |
-
if isinstance(node, ast.Name):
|
| 348 |
-
return float(values.get(node.id, values.get(node.id.upper(), 0.0)))
|
| 349 |
-
raise ValueError(f"Unsupported expression node: {ast.dump(node)}")
|
| 350 |
|
| 351 |
|
| 352 |
# --- Measure dependency analysis -------------------------------------------
|
|
@@ -387,10 +499,7 @@ def _order_measures(measures: list) -> list:
|
|
| 387 |
|
| 388 |
|
| 389 |
def _formula_names(formula: str) -> set[str]:
|
| 390 |
-
|
| 391 |
-
return {n.id for n in ast.walk(ast.parse(formula, mode="eval")) if isinstance(n, ast.Name)}
|
| 392 |
-
except Exception:
|
| 393 |
-
return set()
|
| 394 |
|
| 395 |
|
| 396 |
def _signed_bases(formula: str, measures_by_name: dict, base_names: set[str]) -> tuple[set[str], set[str]]:
|
|
@@ -413,6 +522,11 @@ def _signed_bases(formula: str, measures_by_name: dict, base_names: set[str]) ->
|
|
| 413 |
walk(node.right, -sign, seen)
|
| 414 |
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
| 415 |
walk(node.operand, -sign, seen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
elif isinstance(node, ast.Name):
|
| 417 |
if node.id in base_names:
|
| 418 |
(pos if sign > 0 else neg).add(node.id)
|
|
|
|
| 15 |
|
| 16 |
import ast
|
| 17 |
import math
|
|
|
|
| 18 |
import random
|
| 19 |
from datetime import date, timedelta
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable
|
| 23 |
+
from demoprep_app.dataset.formula import (
|
| 24 |
+
evaluate as eval_formula,
|
| 25 |
+
identifiers as formula_identifiers,
|
| 26 |
+
)
|
| 27 |
from demoprep_app.scenario.blueprint import (
|
| 28 |
BlueprintDimension,
|
| 29 |
BlueprintMeasure,
|
|
|
|
| 49 |
class BlueprintEngine:
|
| 50 |
"""Deterministic executor for DemoBlueprint -> DatasetBundle."""
|
| 51 |
|
| 52 |
+
def __init__(self) -> None:
|
| 53 |
+
# Warnings raised while evaluating formulas (currently: division by
|
| 54 |
+
# zero, which is legitimate but worth surfacing). Everything that is
|
| 55 |
+
# NOT legitimate raises FormulaEvaluationError instead of silently
|
| 56 |
+
# writing 0.0 to every row.
|
| 57 |
+
self._formula_warnings: list[str] = []
|
| 58 |
+
|
| 59 |
+
def formula_warnings(self) -> list[str]:
|
| 60 |
+
"""One deduplicated line per distinct formula warning, with row counts."""
|
| 61 |
+
from collections import Counter
|
| 62 |
+
|
| 63 |
+
counts = Counter(self._formula_warnings)
|
| 64 |
+
return [f"{message} ({count} row(s))" for message, count in sorted(counts.items())]
|
| 65 |
+
|
| 66 |
def generate(self, blueprint: DemoBlueprint, row_count: int | None = None) -> DatasetBundle:
|
| 67 |
+
self._formula_warnings = []
|
| 68 |
rng = random.Random(blueprint.seed or 1)
|
| 69 |
total_target = max(500, int(row_count or 5000))
|
| 70 |
per_fact = max(500, total_target // max(1, len(blueprint.facts)))
|
|
|
|
| 216 |
# Never clamp a derived value: a negative margin / ending
|
| 217 |
# inventory is legitimate and must still satisfy its formula
|
| 218 |
# (the validator re-checks it against the stored values).
|
| 219 |
+
# eval_formula RAISES on an uncomputable formula — it used to
|
| 220 |
+
# return 0.0, which shipped all-zero and fabricated columns.
|
| 221 |
+
values[m.name] = _format_value(
|
| 222 |
+
eval_formula(
|
| 223 |
+
m.formula, values, measure=m.name, warnings=self._formula_warnings
|
| 224 |
+
),
|
| 225 |
+
m.fmt,
|
| 226 |
+
clamp=False,
|
| 227 |
+
)
|
| 228 |
+
continue
|
| 229 |
+
if _is_flag_measure(m):
|
| 230 |
+
# A *_FLAG column is a boolean event marker: 0 or 1, never
|
| 231 |
+
# 5 or 158 (Triumph shipped both). Sample the EVENT, not a
|
| 232 |
+
# magnitude, so any rate built on it is meaningful. Insights
|
| 233 |
+
# and dimension performance move the probability, so a
|
| 234 |
+
# planted story on a flag still shows up.
|
| 235 |
+
flag_p = _flag_probability(m)
|
| 236 |
+
flag_p *= _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d)
|
| 237 |
+
if m.seasonal:
|
| 238 |
+
flag_p *= perf_multiplier
|
| 239 |
+
values[m.name] = 1 if rng.random() < min(max(flag_p, 0.01), 0.99) else 0
|
| 240 |
continue
|
| 241 |
if m.share_of:
|
| 242 |
# "part of a whole": a fraction of the parent's value. The parent
|
|
|
|
| 247 |
low = m.params.get("low", 0.05)
|
| 248 |
high = m.params.get("high", 0.85)
|
| 249 |
imult = _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d)
|
| 250 |
+
# The FRACTION must carry dimension performance, or every
|
| 251 |
+
# rate built as part/whole is identical across the board.
|
| 252 |
+
# perf_multiplier scales the parent AND (without this) the
|
| 253 |
+
# part equally, so it cancels: rate = (whole*p*f)/(whole*p)
|
| 254 |
+
# = f. A collection rate then reads the same for the best and
|
| 255 |
+
# worst provider — AdvancedMD shipped 0.7% spread. Damped,
|
| 256 |
+
# because a rate should move less than the money it derives
|
| 257 |
+
# from: a 40% better provider collects somewhat more of what
|
| 258 |
+
# it bills, not 40% more.
|
| 259 |
+
# Performance moves WHERE IN THE BAND this row lands, it does
|
| 260 |
+
# not push past it. Multiplying the sampled fraction instead
|
| 261 |
+
# drove 16% of rows through the ceiling and pinned them at a
|
| 262 |
+
# 100% collection rate — a clamp artifact that reads as a bug
|
| 263 |
+
# on a chart. [low, high] is the blueprint's statement of what
|
| 264 |
+
# is plausible; a strong performer sits high in that range,
|
| 265 |
+
# not outside it.
|
| 266 |
+
# WARP the sampling curve rather than scaling and clamping.
|
| 267 |
+
# u**(1/p**2) is a smooth monotone map of uniform(0,1) onto
|
| 268 |
+
# itself: a strong performer's draws bunch toward the top of
|
| 269 |
+
# the band, a weak one's toward the bottom, with no hard edge
|
| 270 |
+
# and so no pile-up. Scaling the fraction directly instead
|
| 271 |
+
# pinned 16% of rows at a 100% collection rate; positioning
|
| 272 |
+
# linearly inside the band removed the artifact but flattened
|
| 273 |
+
# the spread back to 5.9%, under the 10-12% the reviewer set.
|
| 274 |
+
frac_perf = max(1.0 + (perf_multiplier - 1.0) * RATE_PERF_SENSITIVITY, 0.05)
|
| 275 |
+
position = min(max(rng.random() ** (1.0 / frac_perf ** 2) * imult, 0.0), 1.0)
|
| 276 |
+
frac = min(max(low + (high - low) * position, 0.0), 1.0)
|
| 277 |
+
values[m.name] = _format_value(
|
| 278 |
+
values.get(m.share_of, 0.0) * frac, m.fmt, pct_max=_pct_bound(m)
|
| 279 |
+
)
|
| 280 |
continue
|
| 281 |
if m.per and f"{m.per}_KEY" in row:
|
| 282 |
# Stable characteristic of an entity: one level per member,
|
|
|
|
| 293 |
mult *= _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d)
|
| 294 |
# Format base values NOW so derived formulas — and the validator,
|
| 295 |
# which re-checks formulas against stored values — see identical numbers.
|
| 296 |
+
values[m.name] = _format_value(
|
| 297 |
+
base * mult, m.fmt, pct_max=_pct_bound(m),
|
| 298 |
+
signed=getattr(m, "signed", False),
|
| 299 |
+
)
|
| 300 |
|
| 301 |
for m in fact.measures:
|
| 302 |
+
if m.name not in values:
|
| 303 |
+
# Dependency ordering covers every measure, so this can only
|
| 304 |
+
# mean an engine bug. Say so instead of writing a silent 0.
|
| 305 |
+
raise RuntimeError(
|
| 306 |
+
f"Fact {fact.name}: measure {m.name} was never evaluated "
|
| 307 |
+
f"(kind={m.kind}, formula='{m.formula}') — refusing to store 0."
|
| 308 |
+
)
|
| 309 |
+
row[m.name] = values[m.name]
|
| 310 |
rows.append(row)
|
| 311 |
|
| 312 |
return DatasetTable(fact.name, fact.grain, columns, rows, is_fact=True)
|
|
|
|
| 342 |
return (1.0 + pct_per_year / 100.0) ** years_elapsed
|
| 343 |
|
| 344 |
|
| 345 |
+
# How strongly a part/whole FRACTION responds to dimension performance. Below
|
| 346 |
+
# 1.0 so rates vary less than the money they come from, and so the bound
|
| 347 |
+
# part <= whole is not constantly pushed against.
|
| 348 |
+
RATE_PERF_SENSITIVITY = 1.0
|
| 349 |
+
|
| 350 |
+
|
| 351 |
def _sample(measure: BlueprintMeasure, rng: random.Random) -> float:
|
| 352 |
p = measure.params
|
| 353 |
if measure.distribution == "uniform":
|
|
|
|
| 355 |
mean = p.get("mean", 1.0)
|
| 356 |
sigma = p.get("sigma", 0.4)
|
| 357 |
if measure.distribution == "normal":
|
| 358 |
+
if getattr(measure, "signed", False):
|
| 359 |
+
# sigma is an ABSOLUTE spread here. A signed measure is centred at
|
| 360 |
+
# or near zero, and the relative branch below (mean * sigma) would
|
| 361 |
+
# collapse to a stddev of 0 — an all-zero column, the exact defect
|
| 362 |
+
# this release exists to remove.
|
| 363 |
+
return rng.gauss(mean, abs(sigma) or 1.0)
|
| 364 |
return max(0.0, rng.gauss(mean, abs(sigma) if sigma > 1 else mean * abs(sigma)))
|
| 365 |
# lognormal: mean is the desired arithmetic mean, sigma the log-space spread
|
| 366 |
sigma = min(max(abs(sigma), 0.05), 1.5)
|
|
|
|
| 386 |
return mult
|
| 387 |
|
| 388 |
|
| 389 |
+
def _format_value(value: float, fmt: str, clamp: bool = True, pct_max: float = 100.0,
|
| 390 |
+
signed: bool = False) -> Any:
|
| 391 |
"""Round a value for storage. clamp bounds sampled base measures to a sane
|
| 392 |
+
range (no negative units, pct within its natural bound); derived measures
|
| 393 |
+
pass clamp=False so their stored value stays faithful to the formula the
|
| 394 |
+
validator checks.
|
| 395 |
+
|
| 396 |
+
pct_max is the measure's natural upper bound: 1.0 for a 0-1 rate, 100.0 for
|
| 397 |
+
a 0-100 percentage. Guessing 100 for a 0-1 rate is how a "utilization rate"
|
| 398 |
+
of 42.0 (meaning 4200%) reaches a KPI tile.
|
| 399 |
+
"""
|
| 400 |
if fmt == "int":
|
| 401 |
v = int(round(value))
|
| 402 |
+
return max(0, v) if (clamp and not signed) else v
|
| 403 |
if fmt in ("currency", "float"):
|
| 404 |
return round(value, 2)
|
| 405 |
if fmt == "pct":
|
| 406 |
if clamp:
|
| 407 |
+
value = min(max(value, -pct_max if signed else 0.0), pct_max)
|
| 408 |
+
return round(value, 4 if pct_max <= 1.0 else 2)
|
| 409 |
return round(value, 2)
|
| 410 |
|
| 411 |
|
| 412 |
+
def _pct_bound(measure: BlueprintMeasure) -> float:
|
| 413 |
+
"""Infer whether a pct measure is on a 0-1 or 0-100 scale from its params.
|
| 414 |
+
|
| 415 |
+
The blueprint does not declare the scale, so read it off the numbers the
|
| 416 |
+
LLM asked for: a mean/high above 1.5 can only mean a 0-100 percentage.
|
| 417 |
+
"""
|
| 418 |
+
if measure.fmt != "pct":
|
| 419 |
+
return 100.0
|
| 420 |
+
declared = [
|
| 421 |
+
measure.params.get(key)
|
| 422 |
+
for key in ("mean", "high", "low")
|
| 423 |
+
if isinstance(measure.params.get(key), (int, float))
|
| 424 |
+
]
|
| 425 |
+
return 100.0 if any(value > 1.5 for value in declared) else 1.0
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _is_flag_measure(measure: BlueprintMeasure) -> bool:
|
| 429 |
+
"""A *_FLAG measure is a 0/1 event marker regardless of what the LLM declared."""
|
| 430 |
+
return measure.name.upper().endswith("_FLAG")
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _flag_probability(measure: BlueprintMeasure) -> float:
|
| 434 |
+
"""Probability that a flag is 1, read from whatever the blueprint declared.
|
| 435 |
+
|
| 436 |
+
Accepts an explicit params.p, else a mean/high already expressed as a
|
| 437 |
+
fraction; anything else (a lognormal mean of 40 for a flag) falls back to
|
| 438 |
+
an even split rather than inventing a magnitude.
|
| 439 |
+
"""
|
| 440 |
+
for key in ("p", "probability"):
|
| 441 |
+
value = measure.params.get(key)
|
| 442 |
+
if isinstance(value, (int, float)) and 0.0 < float(value) <= 1.0:
|
| 443 |
+
return float(value)
|
| 444 |
+
for key in ("mean", "high"):
|
| 445 |
+
value = measure.params.get(key)
|
| 446 |
+
# Strictly < 1: a declared mean of exactly 1 (or 9.6) is a magnitude,
|
| 447 |
+
# not a probability, and reading it as one produces an always-1 flag —
|
| 448 |
+
# which is just the all-constant defect wearing a different hat.
|
| 449 |
+
if isinstance(value, (int, float)) and 0.0 < float(value) < 1.0:
|
| 450 |
+
return float(value)
|
| 451 |
+
return 0.5
|
| 452 |
|
| 453 |
|
| 454 |
+
def _sql_type(measure: BlueprintMeasure) -> str:
|
| 455 |
+
return "NUMBER" if measure.fmt == "int" else "NUMBER(16,2)"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
|
| 457 |
|
| 458 |
+
# --- Formula evaluation ----------------------------------------------------
|
| 459 |
+
# The language (parse, validate, evaluate, compile-to-SQL) lives in
|
| 460 |
+
# demoprep_app/dataset/formula.py so the blueprint validator, this engine, the
|
| 461 |
+
# dataset validator and the Snowflake quality gate all agree on it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
|
| 464 |
# --- Measure dependency analysis -------------------------------------------
|
|
|
|
| 499 |
|
| 500 |
|
| 501 |
def _formula_names(formula: str) -> set[str]:
|
| 502 |
+
return formula_identifiers(formula)
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
|
| 505 |
def _signed_bases(formula: str, measures_by_name: dict, base_names: set[str]) -> tuple[set[str], set[str]]:
|
|
|
|
| 522 |
walk(node.right, -sign, seen)
|
| 523 |
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
| 524 |
walk(node.operand, -sign, seen)
|
| 525 |
+
elif isinstance(node, ast.Call):
|
| 526 |
+
# ABS/ROUND/MIN/MAX/COALESCE preserve the direction of their inputs
|
| 527 |
+
# closely enough to pick a base to scale for an insight.
|
| 528 |
+
for arg in node.args:
|
| 529 |
+
walk(arg, sign, seen)
|
| 530 |
elif isinstance(node, ast.Name):
|
| 531 |
if node.id in base_names:
|
| 532 |
(pos if sign > 0 else neg).add(node.id)
|
demoprep_app/dataset/formula.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The measure-formula language: parse, validate, evaluate, compile to SQL.
|
| 2 |
+
|
| 3 |
+
ONE definition of what a derived-measure formula may contain, shared by
|
| 4 |
+
everything that touches formulas:
|
| 5 |
+
|
| 6 |
+
* blueprint.py — validates formulas BEFORE any generation, Snowflake, or
|
| 7 |
+
ThoughtSpot work happens, and repairs the fixable ones.
|
| 8 |
+
* engine.py — evaluates them per row while generating the dataset.
|
| 9 |
+
* validator.py — re-checks stored values against their formulas.
|
| 10 |
+
* quality_gate.py— recomputes a bad column IN Snowflake via to_sql().
|
| 11 |
+
|
| 12 |
+
Why this module exists (2026-08-26): the evaluator used to swallow every
|
| 13 |
+
failure and return 0.0, so an unsupported function (`ABS(...)`), a typo, or a
|
| 14 |
+
reference to a measure in a DIFFERENT fact table silently produced a column of
|
| 15 |
+
zeros — or, worse, a plausible-looking wrong number (`0 - FORECAST_PRICE`).
|
| 16 |
+
Nothing validated formulas upstream. See
|
| 17 |
+
dev_notes/BLOCKING_ISSUES_2026_08_26.md ISSUE A.
|
| 18 |
+
|
| 19 |
+
Rules of the language:
|
| 20 |
+
* arithmetic: + - * / and unary minus
|
| 21 |
+
* numeric literals
|
| 22 |
+
* identifiers: measures in the SAME fact table, nothing else
|
| 23 |
+
* functions: only the whitelist below (the ones LLMs actually write)
|
| 24 |
+
* division by zero yields 0.0 and records a warning — that one is legitimate
|
| 25 |
+
(a rate with no denominator rows), everything else raises.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import ast
|
| 31 |
+
import math
|
| 32 |
+
import operator
|
| 33 |
+
from typing import Any, Callable, Iterable, Mapping, MutableSequence
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class FormulaEvaluationError(ValueError):
|
| 37 |
+
"""A measure formula could not be evaluated or validated.
|
| 38 |
+
|
| 39 |
+
Carries the measure, the formula, and the offending token so the message
|
| 40 |
+
is actionable instead of a bare "unsupported node".
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, reason: str, *, measure: str = "", formula: str = "", token: str = ""):
|
| 44 |
+
self.reason = reason
|
| 45 |
+
self.measure = measure
|
| 46 |
+
self.formula = formula
|
| 47 |
+
self.token = token
|
| 48 |
+
where = f"measure {measure}" if measure else "formula"
|
| 49 |
+
detail = f" (offending token: {token})" if token else ""
|
| 50 |
+
super().__init__(f"{where}: formula '{formula}' cannot be computed — {reason}{detail}")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --- the whitelist -----------------------------------------------------------
|
| 54 |
+
# (min_args, max_args or None for variadic, python impl, sql template kind)
|
| 55 |
+
|
| 56 |
+
def _round(*args: float) -> float:
|
| 57 |
+
value = args[0]
|
| 58 |
+
digits = int(args[1]) if len(args) > 1 else 0
|
| 59 |
+
return round(value, digits)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _coalesce(*args: float) -> float:
|
| 63 |
+
for value in args:
|
| 64 |
+
if value is not None:
|
| 65 |
+
return value
|
| 66 |
+
return 0.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
ALLOWED_FUNCTIONS: dict[str, tuple[int, int | None, Callable[..., float], str]] = {
|
| 70 |
+
# name min max python sql
|
| 71 |
+
"ABS": (1, 1, lambda x: abs(x), "ABS"),
|
| 72 |
+
"ROUND": (1, 2, _round, "ROUND"),
|
| 73 |
+
"MIN": (2, None, lambda *a: min(a), "LEAST"),
|
| 74 |
+
"MAX": (2, None, lambda *a: max(a), "GREATEST"),
|
| 75 |
+
"LEAST": (2, None, lambda *a: min(a), "LEAST"),
|
| 76 |
+
"GREATEST": (2, None, lambda *a: max(a), "GREATEST"),
|
| 77 |
+
"COALESCE": (1, None, _coalesce, "COALESCE"),
|
| 78 |
+
"IFNULL": (2, 2, _coalesce, "COALESCE"),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
_BIN_OPS: dict[type, Callable[[float, float], float]] = {
|
| 82 |
+
ast.Add: operator.add,
|
| 83 |
+
ast.Sub: operator.sub,
|
| 84 |
+
ast.Mult: operator.mul,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
_SQL_BIN_OPS: dict[type, str] = {
|
| 88 |
+
ast.Add: "+",
|
| 89 |
+
ast.Sub: "-",
|
| 90 |
+
ast.Mult: "*",
|
| 91 |
+
ast.Div: "/",
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
ALLOWED_SYNTAX_SUMMARY = (
|
| 95 |
+
"use + - * / and the functions "
|
| 96 |
+
+ ", ".join(sorted(ALLOWED_FUNCTIONS))
|
| 97 |
+
+ " over other measures in the same fact table"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# --- parsing ----------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def parse(formula: str, *, measure: str = "") -> ast.expr:
|
| 105 |
+
"""Parse a formula into an AST expression, raising a named error."""
|
| 106 |
+
text = (formula or "").strip()
|
| 107 |
+
if not text:
|
| 108 |
+
raise FormulaEvaluationError("formula is empty", measure=measure, formula=formula or "")
|
| 109 |
+
try:
|
| 110 |
+
return ast.parse(text, mode="eval").body
|
| 111 |
+
except SyntaxError as exc:
|
| 112 |
+
raise FormulaEvaluationError(
|
| 113 |
+
f"not valid arithmetic ({exc.msg}); {ALLOWED_SYNTAX_SUMMARY}",
|
| 114 |
+
measure=measure,
|
| 115 |
+
formula=text,
|
| 116 |
+
) from exc
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def identifiers(formula: str) -> set[str]:
|
| 120 |
+
"""Every identifier a formula references. Empty set if it does not parse."""
|
| 121 |
+
try:
|
| 122 |
+
tree = ast.parse((formula or "").strip() or "0", mode="eval")
|
| 123 |
+
except SyntaxError:
|
| 124 |
+
return set()
|
| 125 |
+
return {
|
| 126 |
+
node.id
|
| 127 |
+
for node in ast.walk(tree)
|
| 128 |
+
if isinstance(node, ast.Name) and not _is_function_slot(node, tree)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _is_function_slot(name_node: ast.Name, tree: ast.AST) -> bool:
|
| 133 |
+
"""True when this Name is the callee of a call (a function, not a measure)."""
|
| 134 |
+
for node in ast.walk(tree):
|
| 135 |
+
if isinstance(node, ast.Call) and node.func is name_node:
|
| 136 |
+
return True
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def ratio_pairs(formula: str) -> list[tuple[str, str]]:
|
| 141 |
+
"""Every `A / B` where both sides are bare identifiers.
|
| 142 |
+
|
| 143 |
+
Used two ways: to infer `share_of` (the part must be a fraction of the
|
| 144 |
+
whole) and, in the quality gate, to check part <= whole in Snowflake.
|
| 145 |
+
Finds the pair inside any wrapper — `A / B`, `(A / B) * 100`,
|
| 146 |
+
`ROUND(A / B, 2)` all yield [(A, B)].
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
tree = ast.parse((formula or "").strip() or "0", mode="eval")
|
| 150 |
+
except SyntaxError:
|
| 151 |
+
return []
|
| 152 |
+
pairs: list[tuple[str, str]] = []
|
| 153 |
+
for node in ast.walk(tree):
|
| 154 |
+
if (
|
| 155 |
+
isinstance(node, ast.BinOp)
|
| 156 |
+
and isinstance(node.op, ast.Div)
|
| 157 |
+
and isinstance(node.left, ast.Name)
|
| 158 |
+
and isinstance(node.right, ast.Name)
|
| 159 |
+
):
|
| 160 |
+
pairs.append((node.left.id, node.right.id))
|
| 161 |
+
return pairs
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def validate(formula: str, allowed_identifiers: Iterable[str], *, measure: str = "") -> list[str]:
|
| 165 |
+
"""Report every reason this formula cannot be computed. Empty list == valid.
|
| 166 |
+
|
| 167 |
+
Messages are written for two audiences: the LLM repair round (they go
|
| 168 |
+
straight into the repair prompt) and a human reading the run log.
|
| 169 |
+
"""
|
| 170 |
+
problems: list[str] = []
|
| 171 |
+
try:
|
| 172 |
+
tree = parse(formula, measure=measure)
|
| 173 |
+
except FormulaEvaluationError as exc:
|
| 174 |
+
return [f"Measure {measure}: {exc.reason}." if measure else f"{exc.reason}."]
|
| 175 |
+
|
| 176 |
+
allowed = {str(name) for name in allowed_identifiers}
|
| 177 |
+
prefix = f"Measure {measure}: " if measure else ""
|
| 178 |
+
|
| 179 |
+
for node in ast.walk(tree):
|
| 180 |
+
if isinstance(node, ast.Call):
|
| 181 |
+
func = node.func.id.upper() if isinstance(node.func, ast.Name) else ""
|
| 182 |
+
if func not in ALLOWED_FUNCTIONS:
|
| 183 |
+
shown = func or "<expression>"
|
| 184 |
+
problems.append(
|
| 185 |
+
f"{prefix}formula '{formula}' calls unsupported function {shown}() — "
|
| 186 |
+
f"{ALLOWED_SYNTAX_SUMMARY}."
|
| 187 |
+
)
|
| 188 |
+
continue
|
| 189 |
+
low, high, _, _ = ALLOWED_FUNCTIONS[func]
|
| 190 |
+
argc = len(node.args)
|
| 191 |
+
if node.keywords or argc < low or (high is not None and argc > high):
|
| 192 |
+
problems.append(
|
| 193 |
+
f"{prefix}formula '{formula}' calls {func}() with {argc} argument(s); "
|
| 194 |
+
f"it takes {low}{'+' if high is None else f'..{high}'}."
|
| 195 |
+
)
|
| 196 |
+
elif isinstance(node, ast.Name):
|
| 197 |
+
if _is_function_slot(node, tree):
|
| 198 |
+
continue
|
| 199 |
+
if node.id == measure:
|
| 200 |
+
problems.append(f"{prefix}formula '{formula}' references itself.")
|
| 201 |
+
elif node.id not in allowed:
|
| 202 |
+
problems.append(
|
| 203 |
+
f"{prefix}formula '{formula}' references '{node.id}', which is not a measure "
|
| 204 |
+
f"in the same fact table. Every identifier in a formula must be another "
|
| 205 |
+
f"measure of that fact."
|
| 206 |
+
)
|
| 207 |
+
elif isinstance(node, ast.BinOp):
|
| 208 |
+
if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
| 209 |
+
problems.append(
|
| 210 |
+
f"{prefix}formula '{formula}' uses an unsupported operator — "
|
| 211 |
+
f"{ALLOWED_SYNTAX_SUMMARY}."
|
| 212 |
+
)
|
| 213 |
+
elif isinstance(node, ast.UnaryOp):
|
| 214 |
+
if not isinstance(node.op, ast.USub):
|
| 215 |
+
problems.append(
|
| 216 |
+
f"{prefix}formula '{formula}' uses an unsupported unary operator — "
|
| 217 |
+
f"{ALLOWED_SYNTAX_SUMMARY}."
|
| 218 |
+
)
|
| 219 |
+
elif isinstance(node, ast.Constant):
|
| 220 |
+
if not isinstance(node.value, (int, float)) or isinstance(node.value, bool):
|
| 221 |
+
problems.append(
|
| 222 |
+
f"{prefix}formula '{formula}' contains the non-numeric literal "
|
| 223 |
+
f"{node.value!r} — {ALLOWED_SYNTAX_SUMMARY}."
|
| 224 |
+
)
|
| 225 |
+
elif isinstance(node, (ast.Expression, ast.Load, ast.operator, ast.unaryop, ast.expr_context)):
|
| 226 |
+
# ast.walk() yields operator nodes (Add, Sub, Div...) as well as the
|
| 227 |
+
# BinOp/UnaryOp that owns them; the branches above already checked
|
| 228 |
+
# whether the operator is allowed.
|
| 229 |
+
continue
|
| 230 |
+
else:
|
| 231 |
+
problems.append(
|
| 232 |
+
f"{prefix}formula '{formula}' contains unsupported syntax "
|
| 233 |
+
f"({type(node).__name__}) — {ALLOWED_SYNTAX_SUMMARY}."
|
| 234 |
+
)
|
| 235 |
+
# dedupe, preserve order
|
| 236 |
+
seen: set[str] = set()
|
| 237 |
+
return [p for p in problems if not (p in seen or seen.add(p))]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# --- evaluation -------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def evaluate(
|
| 244 |
+
formula: str,
|
| 245 |
+
values: Mapping[str, Any],
|
| 246 |
+
*,
|
| 247 |
+
measure: str = "",
|
| 248 |
+
warnings: MutableSequence[str] | None = None,
|
| 249 |
+
) -> float:
|
| 250 |
+
"""Evaluate a formula against measure values for ONE row.
|
| 251 |
+
|
| 252 |
+
Raises FormulaEvaluationError naming the measure, formula and offending
|
| 253 |
+
token for anything it cannot compute. It NEVER returns a made-up 0.0 —
|
| 254 |
+
that behavior shipped columns of zeros and fabricated metrics for months.
|
| 255 |
+
|
| 256 |
+
Division by zero returns 0.0 and appends a warning to `warnings` (a rate
|
| 257 |
+
with an empty denominator is legitimate; a silently wrong metric is not).
|
| 258 |
+
"""
|
| 259 |
+
node = parse(formula, measure=measure)
|
| 260 |
+
return float(_eval_node(node, values, measure=measure, formula=formula, warnings=warnings))
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _eval_node(
|
| 264 |
+
node: ast.AST,
|
| 265 |
+
values: Mapping[str, Any],
|
| 266 |
+
*,
|
| 267 |
+
measure: str,
|
| 268 |
+
formula: str,
|
| 269 |
+
warnings: MutableSequence[str] | None,
|
| 270 |
+
) -> float:
|
| 271 |
+
def sub(child: ast.AST) -> float:
|
| 272 |
+
return _eval_node(child, values, measure=measure, formula=formula, warnings=warnings)
|
| 273 |
+
|
| 274 |
+
if isinstance(node, ast.BinOp):
|
| 275 |
+
op_type = type(node.op)
|
| 276 |
+
if op_type in _BIN_OPS:
|
| 277 |
+
return _BIN_OPS[op_type](sub(node.left), sub(node.right))
|
| 278 |
+
if op_type is ast.Div:
|
| 279 |
+
numerator = sub(node.left)
|
| 280 |
+
denominator = sub(node.right)
|
| 281 |
+
if not denominator:
|
| 282 |
+
if warnings is not None:
|
| 283 |
+
warnings.append(
|
| 284 |
+
f"{measure or 'formula'}: division by zero in '{formula}' — stored 0"
|
| 285 |
+
)
|
| 286 |
+
return 0.0
|
| 287 |
+
return numerator / denominator
|
| 288 |
+
raise FormulaEvaluationError(
|
| 289 |
+
f"unsupported operator {op_type.__name__}; {ALLOWED_SYNTAX_SUMMARY}",
|
| 290 |
+
measure=measure, formula=formula, token=op_type.__name__,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if isinstance(node, ast.UnaryOp):
|
| 294 |
+
if isinstance(node.op, ast.USub):
|
| 295 |
+
return -sub(node.operand)
|
| 296 |
+
raise FormulaEvaluationError(
|
| 297 |
+
f"unsupported unary operator {type(node.op).__name__}; {ALLOWED_SYNTAX_SUMMARY}",
|
| 298 |
+
measure=measure, formula=formula, token=type(node.op).__name__,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
if isinstance(node, ast.Call):
|
| 302 |
+
name = node.func.id.upper() if isinstance(node.func, ast.Name) else ""
|
| 303 |
+
spec = ALLOWED_FUNCTIONS.get(name)
|
| 304 |
+
if spec is None or node.keywords:
|
| 305 |
+
raise FormulaEvaluationError(
|
| 306 |
+
f"unsupported function {name or type(node.func).__name__}(); "
|
| 307 |
+
f"{ALLOWED_SYNTAX_SUMMARY}",
|
| 308 |
+
measure=measure, formula=formula, token=name or "call",
|
| 309 |
+
)
|
| 310 |
+
low, high, impl, _ = spec
|
| 311 |
+
args = [sub(arg) for arg in node.args]
|
| 312 |
+
if len(args) < low or (high is not None and len(args) > high):
|
| 313 |
+
raise FormulaEvaluationError(
|
| 314 |
+
f"{name}() takes {low}{'+' if high is None else f'..{high}'} arguments, got {len(args)}",
|
| 315 |
+
measure=measure, formula=formula, token=name,
|
| 316 |
+
)
|
| 317 |
+
result = float(impl(*args))
|
| 318 |
+
if math.isnan(result) or math.isinf(result):
|
| 319 |
+
raise FormulaEvaluationError(
|
| 320 |
+
f"{name}() produced a non-finite value", measure=measure, formula=formula, token=name
|
| 321 |
+
)
|
| 322 |
+
return result
|
| 323 |
+
|
| 324 |
+
if isinstance(node, ast.Constant):
|
| 325 |
+
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
| 326 |
+
raise FormulaEvaluationError(
|
| 327 |
+
f"non-numeric literal {node.value!r}", measure=measure, formula=formula,
|
| 328 |
+
token=repr(node.value),
|
| 329 |
+
)
|
| 330 |
+
return float(node.value)
|
| 331 |
+
|
| 332 |
+
if isinstance(node, ast.Name):
|
| 333 |
+
if node.id in values:
|
| 334 |
+
return float(values[node.id])
|
| 335 |
+
upper = node.id.upper()
|
| 336 |
+
if upper in values:
|
| 337 |
+
return float(values[upper])
|
| 338 |
+
raise FormulaEvaluationError(
|
| 339 |
+
f"'{node.id}' is not a measure of this fact table, so it has no value for this row",
|
| 340 |
+
measure=measure, formula=formula, token=node.id,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
raise FormulaEvaluationError(
|
| 344 |
+
f"unsupported syntax ({type(node).__name__}); {ALLOWED_SYNTAX_SUMMARY}",
|
| 345 |
+
measure=measure, formula=formula, token=type(node).__name__,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# --- SQL compilation (used by the quality gate to recompute in place) -------
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def to_sql(formula: str, columns: Mapping[str, str], *, measure: str = "") -> str:
|
| 353 |
+
"""Compile a formula to a Snowflake expression over `columns`.
|
| 354 |
+
|
| 355 |
+
columns maps UPPERCASE identifier -> real column name. Raises
|
| 356 |
+
FormulaEvaluationError if any identifier is not a column of the table, so
|
| 357 |
+
the caller can fall back to omitting the measure instead of writing SQL
|
| 358 |
+
that references something that does not exist.
|
| 359 |
+
"""
|
| 360 |
+
node = parse(formula, measure=measure)
|
| 361 |
+
return _to_sql_node(node, columns, measure=measure, formula=formula)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _quote(identifier: str) -> str:
|
| 365 |
+
return '"' + identifier.replace('"', '""') + '"'
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _to_sql_node(node: ast.AST, columns: Mapping[str, str], *, measure: str, formula: str) -> str:
|
| 369 |
+
def sub(child: ast.AST) -> str:
|
| 370 |
+
return _to_sql_node(child, columns, measure=measure, formula=formula)
|
| 371 |
+
|
| 372 |
+
if isinstance(node, ast.BinOp):
|
| 373 |
+
op = _SQL_BIN_OPS.get(type(node.op))
|
| 374 |
+
if op is None:
|
| 375 |
+
raise FormulaEvaluationError(
|
| 376 |
+
f"unsupported operator {type(node.op).__name__}", measure=measure, formula=formula,
|
| 377 |
+
token=type(node.op).__name__,
|
| 378 |
+
)
|
| 379 |
+
left, right = sub(node.left), sub(node.right)
|
| 380 |
+
if op == "/":
|
| 381 |
+
# Same semantics as the Python evaluator: /0 -> 0, never NULL.
|
| 382 |
+
return f"IFF(({right}) = 0, 0, ({left}) / NULLIF(({right}), 0))"
|
| 383 |
+
return f"({left} {op} {right})"
|
| 384 |
+
|
| 385 |
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
| 386 |
+
return f"(-{sub(node.operand)})"
|
| 387 |
+
|
| 388 |
+
if isinstance(node, ast.Call):
|
| 389 |
+
name = node.func.id.upper() if isinstance(node.func, ast.Name) else ""
|
| 390 |
+
spec = ALLOWED_FUNCTIONS.get(name)
|
| 391 |
+
if spec is None or node.keywords:
|
| 392 |
+
raise FormulaEvaluationError(
|
| 393 |
+
f"unsupported function {name or 'call'}()", measure=measure, formula=formula,
|
| 394 |
+
token=name or "call",
|
| 395 |
+
)
|
| 396 |
+
low, high, _, sql_name = spec
|
| 397 |
+
args = [sub(a) for a in node.args]
|
| 398 |
+
if len(args) < low or (high is not None and len(args) > high):
|
| 399 |
+
raise FormulaEvaluationError(
|
| 400 |
+
f"{name}() takes {low}{'+' if high is None else f'..{high}'} arguments, got {len(args)}",
|
| 401 |
+
measure=measure, formula=formula, token=name,
|
| 402 |
+
)
|
| 403 |
+
return f"{sql_name}({', '.join(args)})"
|
| 404 |
+
|
| 405 |
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
| 406 |
+
return repr(float(node.value))
|
| 407 |
+
|
| 408 |
+
if isinstance(node, ast.Name):
|
| 409 |
+
actual = columns.get(node.id) or columns.get(node.id.upper())
|
| 410 |
+
if actual is None:
|
| 411 |
+
raise FormulaEvaluationError(
|
| 412 |
+
f"'{node.id}' is not a column of this table",
|
| 413 |
+
measure=measure, formula=formula, token=node.id,
|
| 414 |
+
)
|
| 415 |
+
return _quote(actual)
|
| 416 |
+
|
| 417 |
+
raise FormulaEvaluationError(
|
| 418 |
+
f"unsupported syntax ({type(node).__name__})", measure=measure, formula=formula,
|
| 419 |
+
token=type(node).__name__,
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# --- sign analysis -----------------------------------------------------------
|
| 424 |
+
# Only ever consulted when a formula has to be DEMOTED to a sampled measure
|
| 425 |
+
# (blueprint._repair_one_formula). The sample must keep the sign range the
|
| 426 |
+
# formula would have had: `ACTUAL - FORECAST` is a signed error and sampling it
|
| 427 |
+
# from a strictly-positive lognormal produces a "forecast error" that is never
|
| 428 |
+
# negative — plausible-looking and wrong, which is the failure mode this whole
|
| 429 |
+
# module exists to stop. `ABS(...)` is genuinely one-sided; a difference is not.
|
| 430 |
+
|
| 431 |
+
_SIGN_PRESERVING_FUNCTIONS = frozenset(
|
| 432 |
+
{"ROUND", "MIN", "MAX", "LEAST", "GREATEST", "COALESCE", "IFNULL"}
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def is_signed(formula: str) -> bool:
|
| 437 |
+
"""True when `formula` could legitimately evaluate to a negative number."""
|
| 438 |
+
try:
|
| 439 |
+
return _node_is_signed(parse(formula))
|
| 440 |
+
except FormulaEvaluationError:
|
| 441 |
+
return False
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _node_is_signed(node: ast.AST) -> bool:
|
| 445 |
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
| 446 |
+
name = node.func.id.upper()
|
| 447 |
+
if name in _SIGN_PRESERVING_FUNCTIONS:
|
| 448 |
+
return any(_node_is_signed(arg) for arg in node.args)
|
| 449 |
+
return False # ABS, and anything unrecognised, is non-negative
|
| 450 |
+
if isinstance(node, ast.BinOp):
|
| 451 |
+
if isinstance(node.op, ast.Sub):
|
| 452 |
+
return True
|
| 453 |
+
return _node_is_signed(node.left) or _node_is_signed(node.right)
|
| 454 |
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
| 455 |
+
return True
|
| 456 |
+
return False
|
demoprep_app/dataset/quality.py
CHANGED
|
@@ -204,6 +204,7 @@ def validate_dataset_quality(dataset: DatasetBundle) -> DatasetQualityReport:
|
|
| 204 |
if family:
|
| 205 |
dimension_value_sets.setdefault((table.name, column, family), set()).add(text.lower())
|
| 206 |
|
|
|
|
| 207 |
issues.extend(_duplicate_dimension_set_issues(dimension_value_sets))
|
| 208 |
issues.extend(_scenario_contract_issues(dataset, table_map))
|
| 209 |
issues.extend(_metric_consistency_issues(dataset))
|
|
@@ -359,3 +360,68 @@ def _issue(severity: str, table: str, column: str, value: Any, message: str) ->
|
|
| 359 |
value=str(value)[:255],
|
| 360 |
message=message,
|
| 361 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
if family:
|
| 205 |
dimension_value_sets.setdefault((table.name, column, family), set()).add(text.lower())
|
| 206 |
|
| 207 |
+
issues.extend(_flat_measure_issues(dataset))
|
| 208 |
issues.extend(_duplicate_dimension_set_issues(dimension_value_sets))
|
| 209 |
issues.extend(_scenario_contract_issues(dataset, table_map))
|
| 210 |
issues.extend(_metric_consistency_issues(dataset))
|
|
|
|
| 360 |
value=str(value)[:255],
|
| 361 |
message=message,
|
| 362 |
)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# A measure whose grouped averages differ by less than this across a dimension
|
| 366 |
+
# cannot answer "which one is underperforming" — the question every one of these
|
| 367 |
+
# boards exists to answer. The reviewer set 10-12% from graded boards; 0.10 is
|
| 368 |
+
# the low end so the gate flags only genuinely dead measures.
|
| 369 |
+
MIN_DIMENSION_SPREAD = 0.10
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _flat_measure_issues(dataset: "DatasetBundle") -> list[DatasetQualityIssue]:
|
| 373 |
+
"""Flag measures that are IDENTICAL across the dimensions a board slices by.
|
| 374 |
+
|
| 375 |
+
The existing checks catch an all-ZERO column. All-IDENTICAL is a different
|
| 376 |
+
failure and nearly as demo-fatal: mani.uppala's AdvancedMD build reported
|
| 377 |
+
success with zero warnings while NET_REVENUE varied 2.4% and COLLECTION_RATE
|
| 378 |
+
0.7% across providers, so "which providers are underperforming" — the entire
|
| 379 |
+
point of practice-management analytics — could not be told.
|
| 380 |
+
|
| 381 |
+
WARNING, not blocker, deliberately. A flat board is a weak demo; a blocked
|
| 382 |
+
build is no demo, and no demo is the worse outcome (the governing principle
|
| 383 |
+
behind the repair-not-veto gate). This tells the SE what is wrong while
|
| 384 |
+
still handing them something to show.
|
| 385 |
+
"""
|
| 386 |
+
issues: list[DatasetQualityIssue] = []
|
| 387 |
+
for table in dataset.tables:
|
| 388 |
+
if not table.is_fact or not table.rows:
|
| 389 |
+
continue
|
| 390 |
+
dim_keys = [c.name for c in table.columns if c.semantic_role == "dimension_key"]
|
| 391 |
+
measures = [c.name for c in table.columns
|
| 392 |
+
if c.semantic_role not in {"dimension_key", "date_key", "fact_key", "date"}
|
| 393 |
+
and c.name not in dim_keys]
|
| 394 |
+
for measure in measures:
|
| 395 |
+
# Best spread this measure achieves across ANY dimension the board
|
| 396 |
+
# can group by. Flat on one dimension is fine — a measure priced per
|
| 397 |
+
# payer is legitimately flat by provider — but flat on ALL of them
|
| 398 |
+
# means it cannot carry a chart anywhere.
|
| 399 |
+
best = 0.0
|
| 400 |
+
best_dim = ""
|
| 401 |
+
for dim_key in dim_keys:
|
| 402 |
+
grouped: dict[Any, list[float]] = {}
|
| 403 |
+
for row in table.rows:
|
| 404 |
+
value = _number(row.get(measure))
|
| 405 |
+
if value is not None:
|
| 406 |
+
grouped.setdefault(row.get(dim_key), []).append(value)
|
| 407 |
+
if len(grouped) < 2:
|
| 408 |
+
continue
|
| 409 |
+
averages = [sum(v) / len(v) for v in grouped.values() if v]
|
| 410 |
+
if len(averages) < 2:
|
| 411 |
+
continue
|
| 412 |
+
top = max(averages)
|
| 413 |
+
if not top:
|
| 414 |
+
continue # all-zero: the other gate owns it
|
| 415 |
+
spread = (top - min(averages)) / abs(top)
|
| 416 |
+
if spread > best:
|
| 417 |
+
best, best_dim = spread, dim_key
|
| 418 |
+
if best_dim and best < MIN_DIMENSION_SPREAD:
|
| 419 |
+
issues.append(_issue(
|
| 420 |
+
"warning", table.name, measure, f"{best * 100:.1f}%",
|
| 421 |
+
f"Measure is effectively identical across every dimension "
|
| 422 |
+
f"(best spread {best * 100:.1f}% on {best_dim}, need "
|
| 423 |
+
f"{MIN_DIMENSION_SPREAD * 100:.0f}%). No chart grouped by a "
|
| 424 |
+
f"dimension can show a leader or a laggard, so this measure "
|
| 425 |
+
f"cannot carry a story.",
|
| 426 |
+
))
|
| 427 |
+
return issues
|
demoprep_app/dataset/validator.py
CHANGED
|
@@ -155,20 +155,36 @@ def _check_integrity(blueprint: DemoBlueprint, fact, tables: dict, fact_rows: li
|
|
| 155 |
|
| 156 |
|
| 157 |
def _check_formulas(fact, fact_rows: list[dict[str, Any]], report: ValidationReport) -> None:
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
|
| 160 |
derived = [m for m in fact.measures if m.kind == "derived" and m.formula]
|
| 161 |
for m in derived:
|
| 162 |
sample = fact_rows[:: max(1, len(fact_rows) // 200)] # ~200 spot checks
|
| 163 |
misses = 0
|
| 164 |
for row in sample:
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
actual = float(row.get(m.name, 0.0))
|
| 167 |
tolerance = max(1.0, abs(expected) * 0.02)
|
| 168 |
if abs(actual - expected) > tolerance:
|
| 169 |
misses += 1
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
def _key_lookup(blueprint: DemoBlueprint, tables: dict) -> dict[str, dict[int, str]]:
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def _check_formulas(fact, fact_rows: list[dict[str, Any]], report: ValidationReport) -> None:
|
| 158 |
+
# demoprep_app/dataset/formula.py is the single source of truth for the
|
| 159 |
+
# formula language — the same evaluator the engine used to produce the rows.
|
| 160 |
+
from demoprep_app.dataset.formula import FormulaEvaluationError, evaluate
|
| 161 |
|
| 162 |
derived = [m for m in fact.measures if m.kind == "derived" and m.formula]
|
| 163 |
for m in derived:
|
| 164 |
sample = fact_rows[:: max(1, len(fact_rows) // 200)] # ~200 spot checks
|
| 165 |
misses = 0
|
| 166 |
for row in sample:
|
| 167 |
+
try:
|
| 168 |
+
expected = evaluate(
|
| 169 |
+
m.formula,
|
| 170 |
+
{k: float(v) for k, v in row.items() if isinstance(v, (int, float))},
|
| 171 |
+
measure=m.name,
|
| 172 |
+
)
|
| 173 |
+
except FormulaEvaluationError as exc:
|
| 174 |
+
# The engine could not have produced these rows from this
|
| 175 |
+
# formula either — name it once and move to the next measure
|
| 176 |
+
# instead of counting 200 identical failures.
|
| 177 |
+
report.formula_errors.append(str(exc))
|
| 178 |
+
break
|
| 179 |
actual = float(row.get(m.name, 0.0))
|
| 180 |
tolerance = max(1.0, abs(expected) * 0.02)
|
| 181 |
if abs(actual - expected) > tolerance:
|
| 182 |
misses += 1
|
| 183 |
+
else:
|
| 184 |
+
if misses > len(sample) * 0.05:
|
| 185 |
+
report.formula_errors.append(
|
| 186 |
+
f"{m.name}: {misses}/{len(sample)} sampled rows deviate from formula '{m.formula}'."
|
| 187 |
+
)
|
| 188 |
|
| 189 |
|
| 190 |
def _key_lookup(blueprint: DemoBlueprint, tables: dict) -> dict[str, dict[int, str]]:
|
demoprep_app/integrations/snowflake/__init__.py
CHANGED
|
@@ -2,9 +2,18 @@
|
|
| 2 |
|
| 3 |
from demoprep_app.integrations.snowflake.dataset_writer import populate_dataset_bundle
|
| 4 |
from demoprep_app.integrations.snowflake.quality_gate import (
|
|
|
|
| 5 |
ZeroMeasureError,
|
|
|
|
|
|
|
| 6 |
run_measure_quality_gate,
|
| 7 |
)
|
| 8 |
|
| 9 |
-
__all__ = [
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from demoprep_app.integrations.snowflake.dataset_writer import populate_dataset_bundle
|
| 4 |
from demoprep_app.integrations.snowflake.quality_gate import (
|
| 5 |
+
MeasureQualityGateError,
|
| 6 |
ZeroMeasureError,
|
| 7 |
+
apply_measure_omissions,
|
| 8 |
+
measure_formulas,
|
| 9 |
run_measure_quality_gate,
|
| 10 |
)
|
| 11 |
|
| 12 |
+
__all__ = [
|
| 13 |
+
"populate_dataset_bundle",
|
| 14 |
+
"run_measure_quality_gate",
|
| 15 |
+
"measure_formulas",
|
| 16 |
+
"apply_measure_omissions",
|
| 17 |
+
"MeasureQualityGateError",
|
| 18 |
+
"ZeroMeasureError",
|
| 19 |
+
]
|
demoprep_app/integrations/snowflake/quality_gate.py
CHANGED
|
@@ -1,20 +1,54 @@
|
|
| 1 |
-
"""Post-load data-quality
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
-
from typing import Any, Callable, Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
# Snowflake normalizes numeric types, but accept the common aliases too.
|
| 20 |
_NUMERIC_DATA_TYPES = {
|
|
@@ -27,22 +61,31 @@ _NUMERIC_DATA_TYPES = {
|
|
| 27 |
# (a partial variant of the all-zero defect) but not an outright failure.
|
| 28 |
MOSTLY_ZERO_WARN_THRESHOLD = 0.01
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
self.failures = failures
|
| 36 |
self.profile = profile
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
f"entirely zero/null: {details}. The data generator did not compute "
|
| 44 |
-
f"these derived measures; the run must be regenerated."
|
| 45 |
-
)
|
| 46 |
|
| 47 |
|
| 48 |
def is_measure_column(column_name: str, data_type: str) -> bool:
|
|
@@ -57,35 +100,434 @@ def is_measure_column(column_name: str, data_type: str) -> bool:
|
|
| 57 |
return True
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def _quote_ident(identifier: str) -> str:
|
| 61 |
escaped = identifier.replace('"', '""')
|
| 62 |
return f'"{escaped}"'
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def run_measure_quality_gate(
|
| 66 |
connection: Any,
|
| 67 |
schema_name: str,
|
| 68 |
progress_callback: Optional[Callable[[str], None]] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
) -> dict:
|
| 70 |
-
"""Profile every measure column in the schema;
|
| 71 |
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
{
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
-
Raises
|
| 82 |
-
|
| 83 |
-
|
| 84 |
"""
|
| 85 |
def log(msg: str) -> None:
|
| 86 |
if progress_callback:
|
| 87 |
progress_callback(msg)
|
| 88 |
|
|
|
|
|
|
|
|
|
|
| 89 |
cursor = connection.cursor()
|
| 90 |
try:
|
| 91 |
cursor.execute(
|
|
@@ -113,57 +555,171 @@ def run_measure_quality_gate(
|
|
| 113 |
"tables_checked": 0,
|
| 114 |
"measure_columns_checked": 0,
|
| 115 |
"failures": [],
|
|
|
|
|
|
|
|
|
|
| 116 |
"warnings": [],
|
| 117 |
"columns": {},
|
| 118 |
}
|
| 119 |
|
|
|
|
| 120 |
for table_name, columns in sorted(measures_by_table.items()):
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
q = _quote_ident(col)
|
| 124 |
-
select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} <> 0, 1, 0))")
|
| 125 |
-
cursor.execute(
|
| 126 |
-
f"SELECT {', '.join(select_parts)} "
|
| 127 |
-
f"FROM {_quote_ident(schema_name)}.{_quote_ident(table_name)}"
|
| 128 |
-
)
|
| 129 |
-
result = cursor.fetchone()
|
| 130 |
-
row_count = int(result[0] or 0)
|
| 131 |
-
|
| 132 |
profile["tables_checked"] += 1
|
| 133 |
-
if
|
| 134 |
profile["warnings"].append(
|
| 135 |
f"{table_name} has 0 rows — measure columns could not be verified"
|
| 136 |
)
|
| 137 |
continue
|
| 138 |
-
|
| 139 |
-
for idx, col in enumerate(columns):
|
| 140 |
-
non_zero = int(result[idx + 1] or 0)
|
| 141 |
profile["measure_columns_checked"] += 1
|
| 142 |
profile["columns"][f"{table_name}.{col}"] = {
|
| 143 |
-
"row_count": row_count,
|
| 144 |
-
"non_zero": non_zero,
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
)
|
| 156 |
|
| 157 |
log(
|
| 158 |
f"Data quality gate: profiled {profile['measure_columns_checked']} measure "
|
| 159 |
f"column(s) across {profile['tables_checked']} table(s)"
|
| 160 |
)
|
|
|
|
|
|
|
| 161 |
for warning in profile["warnings"]:
|
| 162 |
log(f"[WARN] Data quality: {warning}")
|
| 163 |
|
| 164 |
-
if profile["
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
return profile
|
| 168 |
finally:
|
| 169 |
cursor.close()
|
|
|
|
| 1 |
+
"""Post-load data-quality REPAIR stage for generated demo schemas.
|
| 2 |
+
|
| 3 |
+
Runs immediately after data population, against what was actually loaded into
|
| 4 |
+
Snowflake, so it is independent of how the data was generated.
|
| 5 |
+
|
| 6 |
+
Why this is a repair stage and not a veto (2026-08-26)
|
| 7 |
+
-----------------------------------------------------
|
| 8 |
+
It used to profile measure columns, raise ZeroMeasureError on the first
|
| 9 |
+
all-zero one, and abort the deployment. On 2026-08-26 that left an SE with a
|
| 10 |
+
schema containing six tables and nothing else: no model, no liveboard, nothing
|
| 11 |
+
to show. An empty schema is indistinguishable from the pipeline never having
|
| 12 |
+
run.
|
| 13 |
+
|
| 14 |
+
Governing principle (boone): producing NOTHING is the worst possible outcome.
|
| 15 |
+
A visible error beats silence; a liveboard with one omitted or obviously broken
|
| 16 |
+
tile beats no liveboard, because it is demo-able and the defect is fixable.
|
| 17 |
+
Fail-open with a reported gap always beats fail-closed with nothing. This gate
|
| 18 |
+
must never be the reason an SE has no demo.
|
| 19 |
+
|
| 20 |
+
So the order of preference is:
|
| 21 |
+
|
| 22 |
+
1. REPAIR in place — recompute a derived column from its formula, clamp a
|
| 23 |
+
part to its whole, binarize a flag, clamp a rate to its scale.
|
| 24 |
+
2. OMIT — drop the measure from the table (and therefore from the model and
|
| 25 |
+
liveboard that are built from it afterwards) and report the omission.
|
| 26 |
+
3. HARD FAIL — only when there is no demo left at all: every measure the
|
| 27 |
+
brief's core claim depends on is unusable.
|
| 28 |
+
|
| 29 |
+
Detection covers more than the all-zero symptom it used to (see
|
| 30 |
+
dev_notes/BLOCKING_ISSUES_2026_08_26.md ISSUE A: the gate blocked on the benign
|
| 31 |
+
symptom, all-zero, and waved through the dangerous one, a plausible-looking
|
| 32 |
+
column that was exactly minus another column):
|
| 33 |
+
|
| 34 |
+
* all zero / null
|
| 35 |
+
* constant (MIN == MAX)
|
| 36 |
+
* exactly equal to +/- another column in the same table
|
| 37 |
+
* *_RATE / *_PCT outside its scale bound
|
| 38 |
+
* *_FLAG not in {0, 1}
|
| 39 |
+
* part > whole, for any part/whole pair implied by a ratio formula
|
| 40 |
"""
|
| 41 |
|
| 42 |
from __future__ import annotations
|
| 43 |
|
| 44 |
+
from typing import Any, Callable, Iterable, Mapping, Optional
|
| 45 |
+
|
| 46 |
+
from demoprep_app.dataset.formula import (
|
| 47 |
+
FormulaEvaluationError,
|
| 48 |
+
identifiers as formula_identifiers,
|
| 49 |
+
ratio_pairs as formula_ratio_pairs,
|
| 50 |
+
to_sql as formula_to_sql,
|
| 51 |
+
)
|
| 52 |
|
| 53 |
# Snowflake normalizes numeric types, but accept the common aliases too.
|
| 54 |
_NUMERIC_DATA_TYPES = {
|
|
|
|
| 61 |
# (a partial variant of the all-zero defect) but not an outright failure.
|
| 62 |
MOSTLY_ZERO_WARN_THRESHOLD = 0.01
|
| 63 |
|
| 64 |
+
# Suffixes that promise a bounded quantity.
|
| 65 |
+
_RATE_SUFFIXES = ("_RATE", "_PCT", "_PERCENT", "_RATIO")
|
| 66 |
+
_FLAG_SUFFIXES = ("_FLAG",)
|
| 67 |
|
| 68 |
+
# Tolerance when comparing MIN/MAX signatures to shortlist mirrored columns.
|
| 69 |
+
_MIRROR_EPS = 1e-9
|
| 70 |
|
| 71 |
+
|
| 72 |
+
class MeasureQualityGateError(Exception):
|
| 73 |
+
"""Nothing demo-able could be produced — the only hard failure left.
|
| 74 |
+
|
| 75 |
+
Raised when every measure the brief's core claim depends on is unusable, or
|
| 76 |
+
when the schema has no usable measure columns at all. Anything narrower is
|
| 77 |
+
repaired or omitted so the build continues.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def __init__(self, message: str, failures: list[dict], profile: dict):
|
| 81 |
self.failures = failures
|
| 82 |
self.profile = profile
|
| 83 |
+
super().__init__(message)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Legacy name: chat_interface and older tests catch this. Keeping it as an alias
|
| 87 |
+
# means an existing `except ZeroMeasureError` still catches the hard failure.
|
| 88 |
+
ZeroMeasureError = MeasureQualityGateError
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def is_measure_column(column_name: str, data_type: str) -> bool:
|
|
|
|
| 100 |
return True
|
| 101 |
|
| 102 |
|
| 103 |
+
def is_flag_column(column_name: str) -> bool:
|
| 104 |
+
return column_name.upper().endswith(_FLAG_SUFFIXES)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def is_rate_column(column_name: str) -> bool:
|
| 108 |
+
return column_name.upper().endswith(_RATE_SUFFIXES)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def rate_scale_bound(avg: float | None) -> float:
|
| 112 |
+
"""Whether a rate column is on the 0-1 or the 0-100 scale.
|
| 113 |
+
|
| 114 |
+
The blueprint does not record the scale, so read it off the data: a genuine
|
| 115 |
+
0-100 percentage column averages well above 1. Known failure mode: a 0-100
|
| 116 |
+
column whose values are nearly all below 1% would be read as a 0-1 rate.
|
| 117 |
+
That is why an out-of-bounds base rate is clamped only when it is clearly
|
| 118 |
+
broken (see _repair_plan) rather than on this signal alone.
|
| 119 |
+
"""
|
| 120 |
+
return 100.0 if (avg is not None and float(avg) > 1.5) else 1.0
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def measure_formulas(bundle: Any) -> dict[str, str]:
|
| 124 |
+
"""{"TABLE.COLUMN": "FORMULA"} for every derived measure in a DatasetBundle.
|
| 125 |
+
|
| 126 |
+
The formulas are what make repair possible: without them a bad column can
|
| 127 |
+
only be omitted.
|
| 128 |
+
"""
|
| 129 |
+
formulas: dict[str, str] = {}
|
| 130 |
+
for table in getattr(bundle, "tables", []) or []:
|
| 131 |
+
for column in table.columns:
|
| 132 |
+
if getattr(column, "formula", None):
|
| 133 |
+
formulas[f"{table.name}.{column.name}"] = column.formula
|
| 134 |
+
return formulas
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def apply_measure_omissions(bundle: Any, omissions: Iterable[Mapping[str, str]]) -> list[str]:
|
| 138 |
+
"""Remove omitted measure columns from an in-memory DatasetBundle.
|
| 139 |
+
|
| 140 |
+
The ThoughtSpot side of the deploy is driven by DDL compiled from this
|
| 141 |
+
bundle, so a column dropped in Snowflake has to disappear here too or the
|
| 142 |
+
model would declare a column that no longer exists.
|
| 143 |
+
"""
|
| 144 |
+
removed: list[str] = []
|
| 145 |
+
by_name = {table.name: table for table in getattr(bundle, "tables", []) or []}
|
| 146 |
+
for omission in omissions:
|
| 147 |
+
table = by_name.get(omission.get("table", ""))
|
| 148 |
+
column = omission.get("column", "")
|
| 149 |
+
if table is None or not column:
|
| 150 |
+
continue
|
| 151 |
+
before = len(table.columns)
|
| 152 |
+
table.columns = [c for c in table.columns if c.name != column]
|
| 153 |
+
if len(table.columns) != before:
|
| 154 |
+
removed.append(f"{table.name}.{column}")
|
| 155 |
+
for row in table.rows:
|
| 156 |
+
row.pop(column, None)
|
| 157 |
+
return removed
|
| 158 |
+
|
| 159 |
+
|
| 160 |
def _quote_ident(identifier: str) -> str:
|
| 161 |
escaped = identifier.replace('"', '""')
|
| 162 |
return f'"{escaped}"'
|
| 163 |
|
| 164 |
|
| 165 |
+
def _table_ref(schema_name: str, table_name: str) -> str:
|
| 166 |
+
return f"{_quote_ident(schema_name)}.{_quote_ident(table_name)}"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
# Profiling
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _profile_table(cursor: Any, schema_name: str, table_name: str, columns: list[str]) -> dict:
|
| 175 |
+
"""One aggregate query per table: row count plus stats for every measure."""
|
| 176 |
+
select_parts = ["COUNT(*)"]
|
| 177 |
+
index_of: dict[str, dict[str, int]] = {}
|
| 178 |
+
for col in columns:
|
| 179 |
+
q = _quote_ident(col)
|
| 180 |
+
slots = {}
|
| 181 |
+
slots["non_zero"] = len(select_parts)
|
| 182 |
+
select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} <> 0, 1, 0))")
|
| 183 |
+
slots["min"] = len(select_parts)
|
| 184 |
+
select_parts.append(f"MIN({q})")
|
| 185 |
+
slots["max"] = len(select_parts)
|
| 186 |
+
select_parts.append(f"MAX({q})")
|
| 187 |
+
slots["avg"] = len(select_parts)
|
| 188 |
+
select_parts.append(f"AVG({q})")
|
| 189 |
+
if is_flag_column(col):
|
| 190 |
+
slots["non_binary"] = len(select_parts)
|
| 191 |
+
select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} NOT IN (0, 1), 1, 0))")
|
| 192 |
+
index_of[col] = slots
|
| 193 |
+
|
| 194 |
+
cursor.execute(f"SELECT {', '.join(select_parts)} FROM {_table_ref(schema_name, table_name)}")
|
| 195 |
+
result = cursor.fetchone()
|
| 196 |
+
row_count = int(result[0] or 0)
|
| 197 |
+
|
| 198 |
+
stats: dict[str, dict] = {}
|
| 199 |
+
for col, slots in index_of.items():
|
| 200 |
+
def value(key: str):
|
| 201 |
+
idx = slots.get(key)
|
| 202 |
+
return None if idx is None else result[idx]
|
| 203 |
+
|
| 204 |
+
stats[col] = {
|
| 205 |
+
"row_count": row_count,
|
| 206 |
+
"non_zero": int(value("non_zero") or 0),
|
| 207 |
+
"min": None if value("min") is None else float(value("min")),
|
| 208 |
+
"max": None if value("max") is None else float(value("max")),
|
| 209 |
+
"avg": None if value("avg") is None else float(value("avg")),
|
| 210 |
+
"non_binary": None if value("non_binary") is None else int(value("non_binary") or 0),
|
| 211 |
+
}
|
| 212 |
+
return {"row_count": row_count, "columns": stats}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _mirror_defects(
|
| 216 |
+
cursor: Any,
|
| 217 |
+
schema_name: str,
|
| 218 |
+
table_name: str,
|
| 219 |
+
stats: dict[str, dict],
|
| 220 |
+
formulas: Mapping[str, str],
|
| 221 |
+
) -> list[dict]:
|
| 222 |
+
"""Columns that are exactly +/- another column of the same table.
|
| 223 |
+
|
| 224 |
+
This is the defect that used to ship silently: a formula referencing a
|
| 225 |
+
measure that does not exist in the fact resolved that identifier to 0, so
|
| 226 |
+
`ACTUAL - FORECAST` stored `-FORECAST` in all 5000 rows and looked
|
| 227 |
+
plausible. Shortlist on the MIN/MAX signature (cheap, from the profile we
|
| 228 |
+
already have), then verify row by row in ONE query.
|
| 229 |
+
"""
|
| 230 |
+
# An all-zero column is already reported as all_zero, and two all-zero
|
| 231 |
+
# columns trivially "mirror" each other — skip them to keep the signal clean.
|
| 232 |
+
names = [
|
| 233 |
+
c for c, s in stats.items()
|
| 234 |
+
if s["row_count"] and s["min"] is not None and s["non_zero"]
|
| 235 |
+
]
|
| 236 |
+
candidates: list[tuple[str, str, int]] = [] # (col, other, sign)
|
| 237 |
+
for col in names:
|
| 238 |
+
formula = formulas.get(f"{table_name}.{col}")
|
| 239 |
+
if not formula:
|
| 240 |
+
continue # only a derived column can be "wrong" in this specific way
|
| 241 |
+
col_min, col_max = stats[col]["min"], stats[col]["max"]
|
| 242 |
+
for other in names:
|
| 243 |
+
if other == col:
|
| 244 |
+
continue
|
| 245 |
+
# A formula that IS just the other column (or its negation) is
|
| 246 |
+
# legitimately identical — don't call that a defect.
|
| 247 |
+
idents = formula_identifiers(formula)
|
| 248 |
+
if idents == {other} and formula.replace(" ", "").lstrip("-") == other:
|
| 249 |
+
continue
|
| 250 |
+
other_min, other_max = stats[other]["min"], stats[other]["max"]
|
| 251 |
+
if abs(col_min - other_min) <= _MIRROR_EPS and abs(col_max - other_max) <= _MIRROR_EPS:
|
| 252 |
+
candidates.append((col, other, 1))
|
| 253 |
+
elif abs(col_min + other_max) <= _MIRROR_EPS and abs(col_max + other_min) <= _MIRROR_EPS:
|
| 254 |
+
candidates.append((col, other, -1))
|
| 255 |
+
|
| 256 |
+
if not candidates:
|
| 257 |
+
return []
|
| 258 |
+
|
| 259 |
+
parts = []
|
| 260 |
+
for col, other, sign in candidates:
|
| 261 |
+
left, right = _quote_ident(col), _quote_ident(other)
|
| 262 |
+
expr = right if sign > 0 else f"-{right}"
|
| 263 |
+
parts.append(f"SUM(IFF({left} IS DISTINCT FROM {expr}, 1, 0))")
|
| 264 |
+
cursor.execute(f"SELECT {', '.join(parts)} FROM {_table_ref(schema_name, table_name)}")
|
| 265 |
+
row = cursor.fetchone()
|
| 266 |
+
|
| 267 |
+
defects: list[dict] = []
|
| 268 |
+
for idx, (col, other, sign) in enumerate(candidates):
|
| 269 |
+
differing = int(row[idx] or 0)
|
| 270 |
+
if differing == 0:
|
| 271 |
+
defects.append({
|
| 272 |
+
"table": table_name,
|
| 273 |
+
"column": col,
|
| 274 |
+
"defect": "mirrors_other_column",
|
| 275 |
+
"detail": (
|
| 276 |
+
f"every row equals {'-' if sign < 0 else ''}{other} — the formula's other "
|
| 277 |
+
f"input resolved to nothing"
|
| 278 |
+
),
|
| 279 |
+
})
|
| 280 |
+
return defects
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _part_whole_defects(
|
| 284 |
+
cursor: Any,
|
| 285 |
+
schema_name: str,
|
| 286 |
+
table_name: str,
|
| 287 |
+
stats: dict[str, dict],
|
| 288 |
+
formulas: Mapping[str, str],
|
| 289 |
+
) -> list[dict]:
|
| 290 |
+
"""part > whole for every part/whole pair implied by a ratio formula."""
|
| 291 |
+
pairs: list[tuple[str, str, str]] = [] # (part, whole, ratio_column)
|
| 292 |
+
for key, formula in formulas.items():
|
| 293 |
+
tbl, _, ratio_col = key.partition(".")
|
| 294 |
+
if tbl != table_name:
|
| 295 |
+
continue
|
| 296 |
+
for part, whole in formula_ratio_pairs(formula):
|
| 297 |
+
if part in stats and whole in stats and (part, whole, ratio_col) not in pairs:
|
| 298 |
+
pairs.append((part, whole, ratio_col))
|
| 299 |
+
if not pairs:
|
| 300 |
+
return []
|
| 301 |
+
|
| 302 |
+
parts_sql = [
|
| 303 |
+
f"SUM(IFF({_quote_ident(part)} > {_quote_ident(whole)}, 1, 0))"
|
| 304 |
+
for part, whole, _ in pairs
|
| 305 |
+
]
|
| 306 |
+
cursor.execute(f"SELECT {', '.join(parts_sql)} FROM {_table_ref(schema_name, table_name)}")
|
| 307 |
+
row = cursor.fetchone()
|
| 308 |
+
|
| 309 |
+
defects: list[dict] = []
|
| 310 |
+
for idx, (part, whole, ratio_col) in enumerate(pairs):
|
| 311 |
+
offending = int(row[idx] or 0)
|
| 312 |
+
if offending:
|
| 313 |
+
defects.append({
|
| 314 |
+
"table": table_name,
|
| 315 |
+
"column": part,
|
| 316 |
+
"defect": "part_exceeds_whole",
|
| 317 |
+
"detail": f"{offending} row(s) where {part} > {whole} (used by {ratio_col})",
|
| 318 |
+
"whole": whole,
|
| 319 |
+
"ratio_column": ratio_col,
|
| 320 |
+
})
|
| 321 |
+
return defects
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _column_defects(table_name: str, stats: dict[str, dict]) -> tuple[list[dict], list[str]]:
|
| 325 |
+
"""Per-column defects and warnings derivable from the profile alone."""
|
| 326 |
+
defects: list[dict] = []
|
| 327 |
+
warnings: list[str] = []
|
| 328 |
+
for col, s in stats.items():
|
| 329 |
+
row_count, non_zero = s["row_count"], s["non_zero"]
|
| 330 |
+
if not row_count:
|
| 331 |
+
continue
|
| 332 |
+
if non_zero == 0:
|
| 333 |
+
defects.append({
|
| 334 |
+
"table": table_name, "column": col, "defect": "all_zero",
|
| 335 |
+
"detail": f"{row_count} rows, all zero/null",
|
| 336 |
+
})
|
| 337 |
+
continue
|
| 338 |
+
if s["min"] is not None and s["max"] is not None and s["min"] == s["max"] and row_count > 1:
|
| 339 |
+
defects.append({
|
| 340 |
+
"table": table_name, "column": col, "defect": "constant",
|
| 341 |
+
"detail": f"every row is {s['min']}",
|
| 342 |
+
})
|
| 343 |
+
if is_flag_column(col) and s.get("non_binary"):
|
| 344 |
+
defects.append({
|
| 345 |
+
"table": table_name, "column": col, "defect": "flag_not_binary",
|
| 346 |
+
"detail": f"{s['non_binary']}/{row_count} rows outside {{0,1}} (max {s['max']})",
|
| 347 |
+
})
|
| 348 |
+
if is_rate_column(col) and s["max"] is not None:
|
| 349 |
+
bound = rate_scale_bound(s["avg"])
|
| 350 |
+
if s["max"] > bound or (s["min"] is not None and s["min"] < -bound):
|
| 351 |
+
defects.append({
|
| 352 |
+
"table": table_name, "column": col, "defect": "rate_out_of_bounds",
|
| 353 |
+
"detail": (
|
| 354 |
+
f"min={s['min']} max={s['max']} avg={s['avg']} outside +/-{bound:g} "
|
| 355 |
+
f"for a rate on that scale"
|
| 356 |
+
),
|
| 357 |
+
"bound": bound,
|
| 358 |
+
})
|
| 359 |
+
if non_zero / row_count < MOSTLY_ZERO_WARN_THRESHOLD:
|
| 360 |
+
pct_zero = 100.0 * (1 - non_zero / row_count)
|
| 361 |
+
warnings.append(
|
| 362 |
+
f"{table_name}.{col} is {pct_zero:.1f}% zero/null ({non_zero}/{row_count} non-zero)"
|
| 363 |
+
)
|
| 364 |
+
return defects, warnings
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ---------------------------------------------------------------------------
|
| 368 |
+
# Repair
|
| 369 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _repair_statements(
|
| 373 |
+
schema_name: str,
|
| 374 |
+
table_name: str,
|
| 375 |
+
defect: dict,
|
| 376 |
+
columns: list[str],
|
| 377 |
+
formulas: Mapping[str, str],
|
| 378 |
+
*,
|
| 379 |
+
allow_clamp: bool = True,
|
| 380 |
+
) -> list[tuple[str, str]]:
|
| 381 |
+
"""(description, SQL) repairs for one defect, most faithful first.
|
| 382 |
+
|
| 383 |
+
Every statement here targets a table this run created seconds earlier in a
|
| 384 |
+
throwaway demo schema. Nothing outside the generated schema is touched.
|
| 385 |
+
"""
|
| 386 |
+
column = defect["column"]
|
| 387 |
+
ref = _table_ref(schema_name, table_name)
|
| 388 |
+
q = _quote_ident(column)
|
| 389 |
+
column_map = {c.upper(): c for c in columns}
|
| 390 |
+
formula = formulas.get(f"{table_name}.{column}")
|
| 391 |
+
|
| 392 |
+
def recompute() -> list[tuple[str, str]]:
|
| 393 |
+
if not formula:
|
| 394 |
+
return []
|
| 395 |
+
try:
|
| 396 |
+
expr = formula_to_sql(formula, column_map, measure=column)
|
| 397 |
+
except FormulaEvaluationError:
|
| 398 |
+
return [] # inputs are not in this table — recompute is impossible
|
| 399 |
+
return [(f"recomputed {table_name}.{column} from '{formula}'", f"UPDATE {ref} SET {q} = {expr}")]
|
| 400 |
+
|
| 401 |
+
if defect["defect"] == "part_exceeds_whole":
|
| 402 |
+
whole = _quote_ident(defect["whole"])
|
| 403 |
+
repairs = [(
|
| 404 |
+
f"clamped {table_name}.{column} to {defect['whole']} so the part cannot exceed the whole",
|
| 405 |
+
f"UPDATE {ref} SET {q} = LEAST({q}, {whole}) WHERE {q} > {whole}",
|
| 406 |
+
)]
|
| 407 |
+
ratio_formula = formulas.get(f"{table_name}.{defect.get('ratio_column', '')}")
|
| 408 |
+
if ratio_formula:
|
| 409 |
+
ratio_col = defect["ratio_column"]
|
| 410 |
+
try:
|
| 411 |
+
expr = formula_to_sql(ratio_formula, column_map, measure=ratio_col)
|
| 412 |
+
except FormulaEvaluationError:
|
| 413 |
+
return repairs
|
| 414 |
+
repairs.append((
|
| 415 |
+
f"recomputed {table_name}.{ratio_col} from '{ratio_formula}' after clamping",
|
| 416 |
+
f"UPDATE {ref} SET {_quote_ident(ratio_col)} = {expr}",
|
| 417 |
+
))
|
| 418 |
+
return repairs
|
| 419 |
+
|
| 420 |
+
if defect["defect"] == "flag_not_binary":
|
| 421 |
+
return [(
|
| 422 |
+
f"binarized {table_name}.{column} to 0/1",
|
| 423 |
+
f"UPDATE {ref} SET {q} = IFF({q} >= 0.5, 1, 0)",
|
| 424 |
+
)]
|
| 425 |
+
|
| 426 |
+
if defect["defect"] == "rate_out_of_bounds":
|
| 427 |
+
# Recomputing from the formula is always safe and always preferred.
|
| 428 |
+
recomputed = recompute()
|
| 429 |
+
if recomputed:
|
| 430 |
+
return recomputed
|
| 431 |
+
if not allow_clamp:
|
| 432 |
+
# Clamping is destructive if the scale was misread, so the caller
|
| 433 |
+
# only allows it when the column is broken beyond any reading.
|
| 434 |
+
return []
|
| 435 |
+
bound = float(defect.get("bound", 1.0))
|
| 436 |
+
return [(
|
| 437 |
+
f"clamped {table_name}.{column} into [0, {bound:g}]",
|
| 438 |
+
f"UPDATE {ref} SET {q} = LEAST(GREATEST({q}, 0), {bound})",
|
| 439 |
+
)]
|
| 440 |
+
|
| 441 |
+
# all_zero / constant / mirrors_other_column: only a formula can fix these.
|
| 442 |
+
return recompute()
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# Repairs run in this order: fix the INPUTS (clamp a part to its whole,
|
| 446 |
+
# binarize a flag) before recomputing anything derived from them, otherwise a
|
| 447 |
+
# recomputed ratio is computed from values that are about to change.
|
| 448 |
+
_REPAIR_ORDER = {"part_exceeds_whole": 0, "flag_not_binary": 1}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _repair_rank(defect: dict) -> int:
|
| 452 |
+
return _REPAIR_ORDER.get(defect["defect"], 2)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# Which unrepaired defects justify removing the measure from the demo.
|
| 456 |
+
# A column that is blank or actively lying is worse than a missing tile; a
|
| 457 |
+
# column that is merely odd (an out-of-scale rate, a part slightly over its
|
| 458 |
+
# whole) is better shown, and reported, than deleted.
|
| 459 |
+
_OMIT_WORTHY = {"all_zero", "mirrors_other_column"}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _should_omit(defect: dict, formulas: Mapping[str, str]) -> bool:
|
| 463 |
+
kind = defect["defect"]
|
| 464 |
+
if kind in _OMIT_WORTHY:
|
| 465 |
+
return True
|
| 466 |
+
if kind == "constant":
|
| 467 |
+
# A derived constant means its formula is broken. A base constant may
|
| 468 |
+
# simply be a flat attribute — keep it and say so.
|
| 469 |
+
return bool(formulas.get(f"{defect['table']}.{defect['column']}"))
|
| 470 |
+
return False
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _is_clamp_worth_it(defect: dict, stats: dict) -> bool:
|
| 474 |
+
"""Guard for the one destructive repair whose trigger is a heuristic."""
|
| 475 |
+
if defect["defect"] != "rate_out_of_bounds":
|
| 476 |
+
return True
|
| 477 |
+
bound = float(defect.get("bound", 1.0))
|
| 478 |
+
maximum = stats.get("max")
|
| 479 |
+
return maximum is not None and (maximum > 100.0 or maximum > bound * 2)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# ---------------------------------------------------------------------------
|
| 483 |
+
# Entry point
|
| 484 |
+
# ---------------------------------------------------------------------------
|
| 485 |
+
|
| 486 |
+
|
| 487 |
def run_measure_quality_gate(
|
| 488 |
connection: Any,
|
| 489 |
schema_name: str,
|
| 490 |
progress_callback: Optional[Callable[[str], None]] = None,
|
| 491 |
+
*,
|
| 492 |
+
formulas: Optional[Mapping[str, str]] = None,
|
| 493 |
+
required_measures: Optional[Iterable[str]] = None,
|
| 494 |
+
repair: bool = True,
|
| 495 |
+
drop_unrepairable: bool = True,
|
| 496 |
) -> dict:
|
| 497 |
+
"""Profile every measure column in the schema; repair, omit, or (rarely) fail.
|
| 498 |
|
| 499 |
+
formulas: {"TABLE.COLUMN": "FORMULA"} for derived measures — see
|
| 500 |
+
measure_formulas(bundle). Without them nothing can be recomputed and a
|
| 501 |
+
bad column can only be omitted.
|
| 502 |
+
required_measures: measure names (bare or "TABLE.COLUMN") the brief's core
|
| 503 |
+
claim depends on. The gate hard-fails only if EVERY one of them ends up
|
| 504 |
+
unusable — one missing tile is not worth denying an SE a demo.
|
| 505 |
+
repair / drop_unrepairable: switches for tests and dry runs.
|
| 506 |
+
|
| 507 |
+
Returns:
|
| 508 |
{
|
| 509 |
+
"tables_checked": int,
|
| 510 |
+
"measure_columns_checked": int,
|
| 511 |
+
"failures": [ ... unrepaired defects that were NOT dropped ... ],
|
| 512 |
+
"defects": [ ... everything detected, with defect type + detail ... ],
|
| 513 |
+
"repairs": ["recomputed FACT.X from '...'", ...],
|
| 514 |
+
"omissions": [{"table":..., "column":..., "defect":..., "detail":...}],
|
| 515 |
+
"warnings": [...],
|
| 516 |
+
"columns": {"TABLE.COLUMN": {"row_count":n, "non_zero":m, "min":..,
|
| 517 |
+
"max":.., "avg":..}},
|
| 518 |
}
|
| 519 |
|
| 520 |
+
Raises MeasureQualityGateError only when there is nothing demo-able left.
|
| 521 |
+
Profiling errors (bad schema name, connection loss) propagate as-is — the
|
| 522 |
+
gate never passes silently.
|
| 523 |
"""
|
| 524 |
def log(msg: str) -> None:
|
| 525 |
if progress_callback:
|
| 526 |
progress_callback(msg)
|
| 527 |
|
| 528 |
+
formulas = dict(formulas or {})
|
| 529 |
+
required = {str(m).upper() for m in (required_measures or ())}
|
| 530 |
+
|
| 531 |
cursor = connection.cursor()
|
| 532 |
try:
|
| 533 |
cursor.execute(
|
|
|
|
| 555 |
"tables_checked": 0,
|
| 556 |
"measure_columns_checked": 0,
|
| 557 |
"failures": [],
|
| 558 |
+
"defects": [],
|
| 559 |
+
"repairs": [],
|
| 560 |
+
"omissions": [],
|
| 561 |
"warnings": [],
|
| 562 |
"columns": {},
|
| 563 |
}
|
| 564 |
|
| 565 |
+
stats_by_table: dict[str, dict] = {}
|
| 566 |
for table_name, columns in sorted(measures_by_table.items()):
|
| 567 |
+
table_profile = _profile_table(cursor, schema_name, table_name, columns)
|
| 568 |
+
stats_by_table[table_name] = table_profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
profile["tables_checked"] += 1
|
| 570 |
+
if not table_profile["row_count"]:
|
| 571 |
profile["warnings"].append(
|
| 572 |
f"{table_name} has 0 rows — measure columns could not be verified"
|
| 573 |
)
|
| 574 |
continue
|
| 575 |
+
for col, stats in table_profile["columns"].items():
|
|
|
|
|
|
|
| 576 |
profile["measure_columns_checked"] += 1
|
| 577 |
profile["columns"][f"{table_name}.{col}"] = {
|
| 578 |
+
"row_count": stats["row_count"],
|
| 579 |
+
"non_zero": stats["non_zero"],
|
| 580 |
+
"min": stats["min"],
|
| 581 |
+
"max": stats["max"],
|
| 582 |
+
"avg": stats["avg"],
|
| 583 |
}
|
| 584 |
+
column_defects, column_warnings = _column_defects(table_name, table_profile["columns"])
|
| 585 |
+
profile["defects"].extend(column_defects)
|
| 586 |
+
profile["warnings"].extend(column_warnings)
|
| 587 |
+
profile["defects"].extend(
|
| 588 |
+
_part_whole_defects(cursor, schema_name, table_name, table_profile["columns"], formulas)
|
| 589 |
+
)
|
| 590 |
+
profile["defects"].extend(
|
| 591 |
+
_mirror_defects(cursor, schema_name, table_name, table_profile["columns"], formulas)
|
| 592 |
+
)
|
|
|
|
| 593 |
|
| 594 |
log(
|
| 595 |
f"Data quality gate: profiled {profile['measure_columns_checked']} measure "
|
| 596 |
f"column(s) across {profile['tables_checked']} table(s)"
|
| 597 |
)
|
| 598 |
+
for defect in profile["defects"]:
|
| 599 |
+
log(f"[DEFECT] {defect['table']}.{defect['column']}: {defect['defect']} — {defect['detail']}")
|
| 600 |
for warning in profile["warnings"]:
|
| 601 |
log(f"[WARN] Data quality: {warning}")
|
| 602 |
|
| 603 |
+
if not profile["defects"]:
|
| 604 |
+
return profile
|
| 605 |
+
|
| 606 |
+
# --- repair pass -------------------------------------------------
|
| 607 |
+
repaired_tables: set[str] = set()
|
| 608 |
+
executed_repairs: set[str] = set()
|
| 609 |
+
if repair:
|
| 610 |
+
for defect in sorted(profile["defects"], key=_repair_rank):
|
| 611 |
+
table_name = defect["table"]
|
| 612 |
+
stats = stats_by_table[table_name]["columns"].get(defect["column"], {})
|
| 613 |
+
allow_clamp = _is_clamp_worth_it(defect, stats)
|
| 614 |
+
statements = _repair_statements(
|
| 615 |
+
schema_name, table_name, defect,
|
| 616 |
+
measures_by_table.get(table_name, []), formulas,
|
| 617 |
+
allow_clamp=allow_clamp,
|
| 618 |
+
)
|
| 619 |
+
if not statements and not allow_clamp:
|
| 620 |
+
profile["warnings"].append(
|
| 621 |
+
f"{table_name}.{defect['column']}: {defect['detail']} — left as-is; "
|
| 622 |
+
"clamping could be wrong if this column is on the 0-100 scale"
|
| 623 |
+
)
|
| 624 |
+
continue
|
| 625 |
+
for description, sql in statements:
|
| 626 |
+
if sql in executed_repairs:
|
| 627 |
+
continue # a column can carry two defects with one fix
|
| 628 |
+
executed_repairs.add(sql)
|
| 629 |
+
cursor.execute(sql)
|
| 630 |
+
profile["repairs"].append(description)
|
| 631 |
+
repaired_tables.add(table_name)
|
| 632 |
+
log(f"[REPAIR] {description}")
|
| 633 |
+
if repaired_tables:
|
| 634 |
+
connection.commit()
|
| 635 |
+
|
| 636 |
+
# --- re-profile only what we touched -----------------------------
|
| 637 |
+
remaining: list[dict] = []
|
| 638 |
+
for defect in profile["defects"]:
|
| 639 |
+
if defect["table"] not in repaired_tables:
|
| 640 |
+
remaining.append(defect)
|
| 641 |
+
for table_name in sorted(repaired_tables):
|
| 642 |
+
columns = measures_by_table.get(table_name, [])
|
| 643 |
+
table_profile = _profile_table(cursor, schema_name, table_name, columns)
|
| 644 |
+
stats_by_table[table_name] = table_profile
|
| 645 |
+
for col, stats in table_profile["columns"].items():
|
| 646 |
+
profile["columns"][f"{table_name}.{col}"] = {
|
| 647 |
+
"row_count": stats["row_count"],
|
| 648 |
+
"non_zero": stats["non_zero"],
|
| 649 |
+
"min": stats["min"],
|
| 650 |
+
"max": stats["max"],
|
| 651 |
+
"avg": stats["avg"],
|
| 652 |
+
}
|
| 653 |
+
column_defects, _ = _column_defects(table_name, table_profile["columns"])
|
| 654 |
+
remaining.extend(column_defects)
|
| 655 |
+
remaining.extend(
|
| 656 |
+
_part_whole_defects(cursor, schema_name, table_name, table_profile["columns"], formulas)
|
| 657 |
+
)
|
| 658 |
+
remaining.extend(
|
| 659 |
+
_mirror_defects(cursor, schema_name, table_name, table_profile["columns"], formulas)
|
| 660 |
+
)
|
| 661 |
|
| 662 |
+
if not remaining:
|
| 663 |
+
log("Data quality gate: all detected defects repaired in place")
|
| 664 |
+
return profile
|
| 665 |
+
|
| 666 |
+
# --- decide the hard-fail BEFORE dropping anything ---------------
|
| 667 |
+
to_omit = [d for d in remaining if _should_omit(d, formulas)]
|
| 668 |
+
for defect in remaining:
|
| 669 |
+
if defect in to_omit:
|
| 670 |
+
continue
|
| 671 |
+
profile["failures"].append(defect)
|
| 672 |
+
profile["warnings"].append(
|
| 673 |
+
f"{defect['table']}.{defect['column']}: {defect['defect']} — {defect['detail']}. "
|
| 674 |
+
"Kept on the board (visible and fixable beats missing)"
|
| 675 |
+
)
|
| 676 |
+
unusable = {f"{d['table']}.{d['column']}".upper() for d in to_omit}
|
| 677 |
+
unusable |= {d["column"].upper() for d in to_omit}
|
| 678 |
+
if required and required <= unusable:
|
| 679 |
+
raise MeasureQualityGateError(
|
| 680 |
+
"Data quality gate: every measure this demo's story depends on is unusable "
|
| 681 |
+
f"({', '.join(sorted(required))}). Nothing demo-able could be produced. "
|
| 682 |
+
f"Schema {schema_name} is left in place for inspection.",
|
| 683 |
+
to_omit, profile,
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
# --- omit what could not be repaired -----------------------------
|
| 687 |
+
for defect in to_omit:
|
| 688 |
+
table_name, column = defect["table"], defect["column"]
|
| 689 |
+
surviving = [
|
| 690 |
+
c for c in measures_by_table.get(table_name, [])
|
| 691 |
+
if c != column and f"{table_name}.{c}".upper() not in
|
| 692 |
+
{f"{o['table']}.{o['column']}".upper() for o in profile["omissions"]}
|
| 693 |
+
]
|
| 694 |
+
if not drop_unrepairable or not surviving:
|
| 695 |
+
# Never strip a table's last measure: a fact with no measures
|
| 696 |
+
# breaks the model outright, which is worse than a bad column.
|
| 697 |
+
reason = (
|
| 698 |
+
"dropping it would leave the table with no measures"
|
| 699 |
+
if surviving == [] else "column removal is disabled for this run"
|
| 700 |
+
)
|
| 701 |
+
profile["failures"].append(defect)
|
| 702 |
+
profile["warnings"].append(
|
| 703 |
+
f"{table_name}.{column}: {defect['defect']} could not be repaired and was "
|
| 704 |
+
f"KEPT ({reason})"
|
| 705 |
+
)
|
| 706 |
+
continue
|
| 707 |
+
cursor.execute(
|
| 708 |
+
f"ALTER TABLE {_table_ref(schema_name, table_name)} DROP COLUMN {_quote_ident(column)}"
|
| 709 |
+
)
|
| 710 |
+
profile["omissions"].append(defect)
|
| 711 |
+
log(
|
| 712 |
+
f"[OMIT] dropped {table_name}.{column} ({defect['defect']}: {defect['detail']}) — "
|
| 713 |
+
"the demo ships without this measure"
|
| 714 |
+
)
|
| 715 |
+
if profile["omissions"]:
|
| 716 |
+
connection.commit()
|
| 717 |
+
|
| 718 |
+
if not profile["columns"]:
|
| 719 |
+
raise MeasureQualityGateError(
|
| 720 |
+
f"Data quality gate: schema {schema_name} has no usable measure columns at all.",
|
| 721 |
+
remaining, profile,
|
| 722 |
+
)
|
| 723 |
return profile
|
| 724 |
finally:
|
| 725 |
cursor.close()
|
demoprep_app/liveboards/blueprint.py
CHANGED
|
@@ -21,8 +21,17 @@ def build_liveboard_blueprint(
|
|
| 21 |
use_case: str,
|
| 22 |
model_columns: List[Dict[str, Any]] | None = None,
|
| 23 |
target_filter_count: int = 3,
|
|
|
|
| 24 |
) -> Dict[str, Any]:
|
| 25 |
-
"""Build a small liveboard blueprint from model metadata and story context.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
model_columns = model_columns or []
|
| 27 |
filters = choose_global_filters(model_columns, target_filter_count=target_filter_count)
|
| 28 |
return {
|
|
@@ -30,7 +39,10 @@ def build_liveboard_blueprint(
|
|
| 30 |
"use_case": use_case or company_data.get("use_case", "Analytics"),
|
| 31 |
"app_name": company_data.get("app_name", DEFAULT_APP_NAME),
|
| 32 |
"filters": filters,
|
| 33 |
-
"demo_notes_tile":
|
|
|
|
|
|
|
|
|
|
| 34 |
"validation_rules": {
|
| 35 |
"filter_count_min": 2,
|
| 36 |
"filter_count_max": 6,
|
|
@@ -218,12 +230,36 @@ def _resolve_demo_guide_id(visualizations: List[Dict[str, Any]], requested_id: s
|
|
| 218 |
|
| 219 |
|
| 220 |
def _place_demo_tile_at_bottom(liveboard: Dict[str, Any], demo_id: str) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
tabs = liveboard.setdefault("layout", {}).setdefault("tabs", [])
|
| 222 |
if not tabs:
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
tiles = target_tab.setdefault("tiles", [])
|
| 226 |
-
tiles[:] = [tile for tile in tiles if tile.get("visualization_id") != demo_id]
|
| 227 |
y = _next_tile_y(tiles)
|
| 228 |
tiles.append({"visualization_id": demo_id, "x": 0, "y": y, "height": 7, "width": 12})
|
| 229 |
|
|
|
|
| 21 |
use_case: str,
|
| 22 |
model_columns: List[Dict[str, Any]] | None = None,
|
| 23 |
target_filter_count: int = 3,
|
| 24 |
+
include_demo_notes_tile: bool = True,
|
| 25 |
) -> Dict[str, Any]:
|
| 26 |
+
"""Build a small liveboard blueprint from model metadata and story context.
|
| 27 |
+
|
| 28 |
+
include_demo_notes_tile gates the "Demo Guide" tile (ISS-5). Golden-demo
|
| 29 |
+
style organises a board with GROUPS, and the tile renders as an awkward
|
| 30 |
+
loose block — worse now that it is appended AFTER the layout pass, so it
|
| 31 |
+
lands ungrouped on whichever tab happens to be last. Callers on the golden
|
| 32 |
+
path pass False. Kept rather than deleted so the standalone/portal callers
|
| 33 |
+
that want it can still ask.
|
| 34 |
+
"""
|
| 35 |
model_columns = model_columns or []
|
| 36 |
filters = choose_global_filters(model_columns, target_filter_count=target_filter_count)
|
| 37 |
return {
|
|
|
|
| 39 |
"use_case": use_case or company_data.get("use_case", "Analytics"),
|
| 40 |
"app_name": company_data.get("app_name", DEFAULT_APP_NAME),
|
| 41 |
"filters": filters,
|
| 42 |
+
"demo_notes_tile": (
|
| 43 |
+
build_demo_notes_spec(company_data, use_case, filters)
|
| 44 |
+
if include_demo_notes_tile else {}
|
| 45 |
+
),
|
| 46 |
"validation_rules": {
|
| 47 |
"filter_count_min": 2,
|
| 48 |
"filter_count_max": 6,
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
def _place_demo_tile_at_bottom(liveboard: Dict[str, Any], demo_id: str) -> None:
|
| 233 |
+
"""Pin the Demo Guide tile to the bottom of the SHOWCASE tab.
|
| 234 |
+
|
| 235 |
+
boone's ruling 2026-09-02 (closes ISS-5): the tile MOVES to the third page —
|
| 236 |
+
not removed, not gated. The third page is the ungraded experiment zone, so a
|
| 237 |
+
text tile there does not violate golden-demo style. If the layout has no
|
| 238 |
+
showcase tab yet (a board with no tables or custom charts), create it: the
|
| 239 |
+
tile must never fall back onto the executive or manager tab.
|
| 240 |
+
"""
|
| 241 |
+
from demoprep_app.liveboards.golden_layout import SHOWCASE_TAB
|
| 242 |
+
|
| 243 |
tabs = liveboard.setdefault("layout", {}).setdefault("tabs", [])
|
| 244 |
if not tabs:
|
| 245 |
+
# A board with no layout yet must still lead with the executive tab; the
|
| 246 |
+
# showcase tab is appended below. Never let "Random Charts" be page one.
|
| 247 |
+
tabs.append({"name": "Executive Overview", "tiles": [], "group_layouts": []})
|
| 248 |
+
# Remove any previous placement wherever it was.
|
| 249 |
+
for tab in tabs:
|
| 250 |
+
tiles = tab.get("tiles") or []
|
| 251 |
+
tiles[:] = [tile for tile in tiles if tile.get("visualization_id") != demo_id]
|
| 252 |
+
|
| 253 |
+
target_tab = next((tab for tab in tabs if tab.get("name") == SHOWCASE_TAB), None)
|
| 254 |
+
if target_tab is None:
|
| 255 |
+
target_tab = {
|
| 256 |
+
"name": SHOWCASE_TAB,
|
| 257 |
+
"description": "Custom visuals, exploratory charts, and the demo guide",
|
| 258 |
+
"tiles": [],
|
| 259 |
+
"group_layouts": [],
|
| 260 |
+
}
|
| 261 |
+
tabs.append(target_tab)
|
| 262 |
tiles = target_tab.setdefault("tiles", [])
|
|
|
|
| 263 |
y = _next_tile_y(tiles)
|
| 264 |
tiles.append({"visualization_id": demo_id, "x": 0, "y": y, "height": 7, "width": 12})
|
| 265 |
|
demoprep_app/liveboards/golden_layout.py
CHANGED
|
@@ -14,6 +14,9 @@ from typing import Any, Dict, Iterable, List, Tuple
|
|
| 14 |
|
| 15 |
EXECUTIVE_TAB = "Executive Overview"
|
| 16 |
OPERATIONS_TAB = "Manager Drilldown"
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def build_dashboard_plan(
|
|
@@ -31,8 +34,11 @@ def build_dashboard_plan(
|
|
| 31 |
and lead with the KPI row. Set True to retain a single branded overview tile.
|
| 32 |
"""
|
| 33 |
target_viz_count = max(8, min(int(target_viz_count or 16), 24))
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
executive_tab, manager_tab = _tab_names_for_use_case(use_case)
|
|
|
|
| 36 |
return {
|
| 37 |
"company_name": company_name,
|
| 38 |
"use_case": use_case,
|
|
@@ -42,6 +48,7 @@ def build_dashboard_plan(
|
|
| 42 |
"model_columns": model_columns,
|
| 43 |
"executive_tab": executive_tab,
|
| 44 |
"manager_tab": manager_tab,
|
|
|
|
| 45 |
"target_viz_count": target_viz_count,
|
| 46 |
"audiences": [
|
| 47 |
{
|
|
@@ -54,6 +61,11 @@ def build_dashboard_plan(
|
|
| 54 |
"tab": manager_tab,
|
| 55 |
"intent": "Operator-level breakdowns for action, ownership, and follow-up.",
|
| 56 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
],
|
| 58 |
"sections": [
|
| 59 |
{
|
|
@@ -98,6 +110,12 @@ def build_dashboard_plan(
|
|
| 98 |
"role": "detail",
|
| 99 |
"target_count": 2,
|
| 100 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
],
|
| 102 |
"style": {
|
| 103 |
"border": "CURVED",
|
|
@@ -160,17 +178,39 @@ def apply_golden_two_tab_layout(
|
|
| 160 |
# Auto-build a geo map when the model has coordinates and the board has none.
|
| 161 |
geo_viz = _maybe_build_geo_viz(data_visualizations, plan.get("model_columns"))
|
| 162 |
geo_change = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
if geo_viz is not None:
|
| 164 |
data_visualizations.append(geo_viz)
|
| 165 |
visualizations.append(geo_viz)
|
| 166 |
liveboard["visualizations"] = visualizations
|
| 167 |
geo_change = f"Built geo map: {geo_viz['answer']['name']}"
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
kpi_changes = _convert_periodic_metric_charts(data_visualizations)
|
|
|
|
|
|
|
| 170 |
chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations)
|
|
|
|
|
|
|
| 171 |
mode_changes = _force_chart_mode(data_visualizations)
|
| 172 |
color_changes = _apply_color_by_dimension(data_visualizations)
|
| 173 |
-
|
|
|
|
| 174 |
# Cosmetic title cleanup AFTER classification so roles use the original names.
|
| 175 |
title_changes = _clean_viz_titles(data_visualizations)
|
| 176 |
if note_viz:
|
|
@@ -184,13 +224,19 @@ def apply_golden_two_tab_layout(
|
|
| 184 |
f"Applied golden two-tab layout ({len(groups)} groups)",
|
| 185 |
f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}",
|
| 186 |
]
|
| 187 |
-
for change in (geo_change,
|
|
|
|
|
|
|
| 188 |
if change:
|
| 189 |
changes.append(change)
|
| 190 |
return updated, changes
|
| 191 |
|
| 192 |
|
| 193 |
-
def _build_groups(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
buckets = {
|
| 195 |
"kpi": [],
|
| 196 |
"geo": [],
|
|
@@ -199,6 +245,7 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) ->
|
|
| 199 |
"breakdown": [],
|
| 200 |
"ranking": [],
|
| 201 |
"detail": [],
|
|
|
|
| 202 |
}
|
| 203 |
|
| 204 |
for viz in visualizations:
|
|
@@ -210,7 +257,8 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) ->
|
|
| 210 |
|
| 211 |
overflow = []
|
| 212 |
assigned = set()
|
| 213 |
-
for role in ("kpi", "geo", "trend", "driver", "breakdown", "ranking",
|
|
|
|
| 214 |
assigned.update(buckets[role])
|
| 215 |
for viz in visualizations:
|
| 216 |
viz_id = viz.get("id", "")
|
|
@@ -218,7 +266,12 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) ->
|
|
| 218 |
overflow.append(viz_id)
|
| 219 |
buckets["breakdown"].extend(overflow)
|
| 220 |
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
groups = []
|
| 224 |
for section in plan.get("sections", []):
|
|
@@ -241,33 +294,139 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) ->
|
|
| 241 |
return groups
|
| 242 |
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
def _promote_executive_analysis(buckets: Dict[str, List[str]]) -> None:
|
| 249 |
-
"""Keep the first tab substantial (~EXEC_ANALYSIS_TARGET analysis charts) by
|
| 250 |
-
promoting breakdown/ranking charts to drivers — without dropping the manager
|
| 251 |
-
tab below MANAGER_MIN charts."""
|
| 252 |
-
executive_analysis_count = len(buckets["trend"]) + len(buckets["driver"])
|
| 253 |
-
if executive_analysis_count >= EXEC_ANALYSIS_TARGET:
|
| 254 |
-
return
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
return
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
return
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
def _build_tabs(
|
|
@@ -278,32 +437,36 @@ def _build_tabs(
|
|
| 278 |
plan = plan or {}
|
| 279 |
executive_tab = _executive_tab_name(plan)
|
| 280 |
manager_tab = _manager_tab_name(plan)
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
if not executive_groups:
|
| 285 |
executive_groups = groups[:1]
|
| 286 |
-
operations_groups = groups[1:]
|
| 287 |
|
| 288 |
-
tabs = [
|
| 289 |
-
tabs.append(
|
| 290 |
{
|
| 291 |
"name": executive_tab,
|
| 292 |
"description": "Executive-ready performance summary and key drivers",
|
| 293 |
"tiles": _top_level_tiles(executive_groups, note_viz_id=note_viz_id),
|
| 294 |
"group_layouts": [_group_layout(group) for group in executive_groups],
|
| 295 |
}
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
"
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
return tabs
|
| 308 |
|
| 309 |
|
|
@@ -364,8 +527,11 @@ def _group_layout(group: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 364 |
return {"id": group.get("id", ""), "tiles": tiles}
|
| 365 |
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
def _chart_group_widths(n: int) -> List[int]:
|
|
@@ -401,11 +567,12 @@ def _apply_style(
|
|
| 401 |
{
|
| 402 |
"lb_border_type": style_config.get("border", "CURVED"),
|
| 403 |
"lb_brand_color": style_config.get("brand_color", "LBC_A"),
|
|
|
|
| 404 |
"hide_group_title": "false",
|
| 405 |
-
"hide_group_description": "
|
| 406 |
"hide_group_tile_description": "false",
|
| 407 |
-
"hide_tile_description": "
|
| 408 |
-
"kpi_hero_font_size": "
|
| 409 |
},
|
| 410 |
)
|
| 411 |
|
|
@@ -504,6 +671,9 @@ def _logo_block(company_name: str, logo_url: str | None) -> str:
|
|
| 504 |
)
|
| 505 |
|
| 506 |
|
|
|
|
|
|
|
|
|
|
| 507 |
def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> str:
|
| 508 |
"""Keep heatmaps rare and add varied chart colors to avoid all-blue boards."""
|
| 509 |
heatmaps_seen = 0
|
|
@@ -517,7 +687,7 @@ def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) ->
|
|
| 517 |
|
| 518 |
if chart_type == "HEATMAP":
|
| 519 |
heatmaps_seen += 1
|
| 520 |
-
if heatmaps_seen >
|
| 521 |
chart["type"] = _replacement_chart_type(answer)
|
| 522 |
chart_type = chart["type"]
|
| 523 |
heatmaps_converted += 1
|
|
@@ -534,13 +704,175 @@ def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) ->
|
|
| 534 |
return "; ".join(parts)
|
| 535 |
|
| 536 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
def _force_chart_mode(visualizations: List[Dict[str, Any]]) -> str:
|
| 538 |
"""Charts rendering as data grids (display_mode=TABLE_MODE) but with a real
|
| 539 |
chart type should display as charts. Genuine tables/KPIs are left alone."""
|
| 540 |
fixed = 0
|
| 541 |
for viz in visualizations:
|
| 542 |
chart_type = _chart_type(viz)
|
| 543 |
-
if chart_type in {"TABLE", "PIVOT_TABLE", "KPI", "NOTE", "UNKNOWN"}:
|
| 544 |
continue
|
| 545 |
answer = viz.get("answer", {})
|
| 546 |
if answer.get("display_mode") != "CHART_MODE":
|
|
@@ -600,6 +932,118 @@ _GEO_DIM_HINTS = (
|
|
| 600 |
)
|
| 601 |
|
| 602 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
def detect_geo_columns(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None:
|
| 604 |
"""From a model's columns [{name, type}], find lat/lon + a geo attribute + a
|
| 605 |
measure. Returns {lat, lon, geodim, measure} or None if not geographic."""
|
|
@@ -670,6 +1114,148 @@ def _build_geo_viz(sibling_answer: Dict[str, Any], geo: Dict[str, str], viz_id:
|
|
| 670 |
}
|
| 671 |
|
| 672 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
def _maybe_build_geo_viz(
|
| 674 |
visualizations: List[Dict[str, Any]],
|
| 675 |
model_columns: List[Dict[str, Any]] | None,
|
|
@@ -680,13 +1266,18 @@ def _maybe_build_geo_viz(
|
|
| 680 |
if any(str(v.get("answer", {}).get("chart", {}).get("type", "")).startswith("GEO")
|
| 681 |
for v in visualizations if "note_tile" not in v):
|
| 682 |
return None
|
| 683 |
-
geo = detect_geo_columns(model_columns)
|
| 684 |
-
if not geo:
|
| 685 |
-
return None
|
| 686 |
sibling = next((v.get("answer", {}) for v in visualizations
|
| 687 |
if "note_tile" not in v and v.get("answer", {}).get("tables")), None)
|
| 688 |
if not sibling:
|
| 689 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
return _build_geo_viz(sibling, geo)
|
| 691 |
|
| 692 |
|
|
@@ -746,7 +1337,10 @@ def _convert_periodic_metric_charts(visualizations: List[Dict[str, Any]]) -> str
|
|
| 746 |
{"column_id": date_column},
|
| 747 |
{"column_id": measure_column},
|
| 748 |
]
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
| 750 |
converted += 1
|
| 751 |
|
| 752 |
if converted:
|
|
@@ -754,6 +1348,74 @@ def _convert_periodic_metric_charts(visualizations: List[Dict[str, Any]]) -> str
|
|
| 754 |
return ""
|
| 755 |
|
| 756 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
def _kpi_axis_columns(answer: Dict[str, Any]) -> Tuple[str | None, str | None]:
|
| 758 |
columns = _answer_column_names(answer)
|
| 759 |
if len(columns) < 2:
|
|
@@ -810,7 +1472,60 @@ def _looks_like_date_column(column: str) -> bool:
|
|
| 810 |
)
|
| 811 |
|
| 812 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
def _replacement_chart_type(answer: Dict[str, Any]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
name = answer.get("name", "").lower()
|
| 815 |
query = answer.get("search_query", "").lower()
|
| 816 |
text = f"{name} {query}"
|
|
@@ -928,6 +1643,10 @@ def _executive_tab_name(plan: Dict[str, Any]) -> str:
|
|
| 928 |
return plan.get("executive_tab") or EXECUTIVE_TAB
|
| 929 |
|
| 930 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
def _manager_tab_name(plan: Dict[str, Any]) -> str:
|
| 932 |
return plan.get("manager_tab") or OPERATIONS_TAB
|
| 933 |
|
|
@@ -1000,11 +1719,19 @@ def _role_for_viz(viz: Dict[str, Any]) -> str:
|
|
| 1000 |
return "geo"
|
| 1001 |
if "TABLE" in chart_type:
|
| 1002 |
return "detail"
|
| 1003 |
-
# A single-metric periodic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
if (
|
| 1005 |
-
chart_type in {"
|
| 1006 |
and any(period in text for period in ("weekly", "monthly", "quarterly", "daily", "yearly"))
|
| 1007 |
and " by " not in text
|
|
|
|
| 1008 |
):
|
| 1009 |
return "kpi"
|
| 1010 |
# Rankings before breakdowns: "Top 10 X by Y" is a ranking, not a plain breakdown.
|
|
|
|
| 14 |
|
| 15 |
EXECUTIVE_TAB = "Executive Overview"
|
| 16 |
OPERATIONS_TAB = "Manager Drilldown"
|
| 17 |
+
# Third tab: the grab-bag. Custom/Muze charts, the one sanctioned table, and any
|
| 18 |
+
# chart too interesting to cut but too odd for the first two tabs.
|
| 19 |
+
SHOWCASE_TAB = "Random Charts"
|
| 20 |
|
| 21 |
|
| 22 |
def build_dashboard_plan(
|
|
|
|
| 34 |
and lead with the KPI row. Set True to retain a single branded overview tile.
|
| 35 |
"""
|
| 36 |
target_viz_count = max(8, min(int(target_viz_count or 16), 24))
|
| 37 |
+
# Four KPIs across the top is THE standard (both golden reference boards),
|
| 38 |
+
# independent of how many visualizations the board has.
|
| 39 |
+
kpi_count = KPI_STANDARD_COUNT
|
| 40 |
executive_tab, manager_tab = _tab_names_for_use_case(use_case)
|
| 41 |
+
showcase_tab = SHOWCASE_TAB
|
| 42 |
return {
|
| 43 |
"company_name": company_name,
|
| 44 |
"use_case": use_case,
|
|
|
|
| 48 |
"model_columns": model_columns,
|
| 49 |
"executive_tab": executive_tab,
|
| 50 |
"manager_tab": manager_tab,
|
| 51 |
+
"showcase_tab": showcase_tab,
|
| 52 |
"target_viz_count": target_viz_count,
|
| 53 |
"audiences": [
|
| 54 |
{
|
|
|
|
| 61 |
"tab": manager_tab,
|
| 62 |
"intent": "Operator-level breakdowns for action, ownership, and follow-up.",
|
| 63 |
},
|
| 64 |
+
{
|
| 65 |
+
"name": "Analyst",
|
| 66 |
+
"tab": showcase_tab,
|
| 67 |
+
"intent": "Exploratory and custom visuals, plus row-level detail.",
|
| 68 |
+
},
|
| 69 |
],
|
| 70 |
"sections": [
|
| 71 |
{
|
|
|
|
| 110 |
"role": "detail",
|
| 111 |
"target_count": 2,
|
| 112 |
},
|
| 113 |
+
{
|
| 114 |
+
"name": "Chart Showcase",
|
| 115 |
+
"tab": showcase_tab,
|
| 116 |
+
"role": "showcase",
|
| 117 |
+
"target_count": SHOWCASE_TAB_TARGET,
|
| 118 |
+
},
|
| 119 |
],
|
| 120 |
"style": {
|
| 121 |
"border": "CURVED",
|
|
|
|
| 178 |
# Auto-build a geo map when the model has coordinates and the board has none.
|
| 179 |
geo_viz = _maybe_build_geo_viz(data_visualizations, plan.get("model_columns"))
|
| 180 |
geo_change = ""
|
| 181 |
+
if geo_viz is None:
|
| 182 |
+
untagged = untagged_geo_candidates(plan.get("model_columns"))
|
| 183 |
+
if untagged:
|
| 184 |
+
geo_change = (
|
| 185 |
+
"No geo map: " + ", ".join(untagged[:3]) + " look geographic but "
|
| 186 |
+
"carry no geo_config in the model, and ThoughtSpot renders an "
|
| 187 |
+
"untagged GEO_* chart as a blank map. Fix the model, not the board."
|
| 188 |
+
)
|
| 189 |
if geo_viz is not None:
|
| 190 |
data_visualizations.append(geo_viz)
|
| 191 |
visualizations.append(geo_viz)
|
| 192 |
liveboard["visualizations"] = visualizations
|
| 193 |
geo_change = f"Built geo map: {geo_viz['answer']['name']}"
|
| 194 |
|
| 195 |
+
# One detail table for the showcase tab, built the same way as the geo map.
|
| 196 |
+
table_viz = _maybe_build_detail_table(data_visualizations, plan.get("model_columns"))
|
| 197 |
+
table_change = ""
|
| 198 |
+
if table_viz is not None:
|
| 199 |
+
data_visualizations.append(table_viz)
|
| 200 |
+
visualizations.append(table_viz)
|
| 201 |
+
liveboard["visualizations"] = visualizations
|
| 202 |
+
table_change = f"Built detail table: {table_viz['answer']['name']}"
|
| 203 |
+
|
| 204 |
kpi_changes = _convert_periodic_metric_charts(data_visualizations)
|
| 205 |
+
sparkline_changes = _fix_kpi_sparklines(data_visualizations)
|
| 206 |
+
muze_changes = _muze_upgrade_surplus_heatmaps(data_visualizations)
|
| 207 |
chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations)
|
| 208 |
+
# After every type mutation above, and covering types MCP chose itself.
|
| 209 |
+
axis_changes = repair_secondary_axis_mismatch(data_visualizations)
|
| 210 |
mode_changes = _force_chart_mode(data_visualizations)
|
| 211 |
color_changes = _apply_color_by_dimension(data_visualizations)
|
| 212 |
+
group_notes: List[str] = []
|
| 213 |
+
groups = _build_groups(data_visualizations, plan, notes=group_notes)
|
| 214 |
# Cosmetic title cleanup AFTER classification so roles use the original names.
|
| 215 |
title_changes = _clean_viz_titles(data_visualizations)
|
| 216 |
if note_viz:
|
|
|
|
| 224 |
f"Applied golden two-tab layout ({len(groups)} groups)",
|
| 225 |
f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}",
|
| 226 |
]
|
| 227 |
+
for change in (geo_change, table_change, kpi_changes, sparkline_changes, chart_changes,
|
| 228 |
+
axis_changes, muze_changes, mode_changes, color_changes,
|
| 229 |
+
title_changes, *group_notes):
|
| 230 |
if change:
|
| 231 |
changes.append(change)
|
| 232 |
return updated, changes
|
| 233 |
|
| 234 |
|
| 235 |
+
def _build_groups(
|
| 236 |
+
visualizations: List[Dict[str, Any]],
|
| 237 |
+
plan: Dict[str, Any],
|
| 238 |
+
notes: List[str] | None = None,
|
| 239 |
+
) -> List[Dict[str, Any]]:
|
| 240 |
buckets = {
|
| 241 |
"kpi": [],
|
| 242 |
"geo": [],
|
|
|
|
| 245 |
"breakdown": [],
|
| 246 |
"ranking": [],
|
| 247 |
"detail": [],
|
| 248 |
+
"showcase": [],
|
| 249 |
}
|
| 250 |
|
| 251 |
for viz in visualizations:
|
|
|
|
| 257 |
|
| 258 |
overflow = []
|
| 259 |
assigned = set()
|
| 260 |
+
for role in ("kpi", "geo", "trend", "driver", "breakdown", "ranking",
|
| 261 |
+
"detail", "showcase"):
|
| 262 |
assigned.update(buckets[role])
|
| 263 |
for viz in visualizations:
|
| 264 |
viz_id = viz.get("id", "")
|
|
|
|
| 266 |
overflow.append(viz_id)
|
| 267 |
buckets["breakdown"].extend(overflow)
|
| 268 |
|
| 269 |
+
_fill_showcase(buckets, visualizations)
|
| 270 |
+
quarantine_note = _quarantine_problem_vizzes(buckets, visualizations)
|
| 271 |
+
if quarantine_note and notes is not None:
|
| 272 |
+
notes.append(quarantine_note)
|
| 273 |
+
_demote_heatmaps(buckets, visualizations)
|
| 274 |
+
_rebalance_tabs(buckets)
|
| 275 |
|
| 276 |
groups = []
|
| 277 |
for section in plan.get("sections", []):
|
|
|
|
| 294 |
return groups
|
| 295 |
|
| 296 |
|
| 297 |
+
MANAGER_TAB_TARGET = 6 # tab 2 is a themed drilldown
|
| 298 |
+
SHOWCASE_TAB_TARGET = 4 # tab 3 is a short grab-bag
|
| 299 |
+
EXEC_MIN_ANALYSIS = 4 # non-KPI charts the exec tab keeps before later tabs fill
|
| 300 |
+
MANAGER_MIN_TILES = 3 # handoff 2026-09-02: a one-viz manager tab is a failure; floor is 3
|
| 301 |
+
_MANAGER_ROLES = ("breakdown", "ranking", "detail")
|
| 302 |
+
_EXEC_ANALYSIS_ROLES = ("trend", "driver")
|
| 303 |
|
| 304 |
+
# Chart types that belong on the showcase tab: hand-authored/custom visuals and
|
| 305 |
+
# the exotic types we would not put in front of an executive.
|
| 306 |
+
SHOWCASE_CHART_TYPES = frozenset({
|
| 307 |
+
"MUZE_STUDIO", "CUSTOM_CHART", "SANKEY", "SPIDER_WEB", "PIE",
|
| 308 |
+
})
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
+
def _is_table_viz(viz: Dict[str, Any]) -> bool:
|
| 312 |
+
return "TABLE" in _chart_type(viz)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _demote_heatmaps(
|
| 316 |
+
buckets: Dict[str, List[str]],
|
| 317 |
+
visualizations: List[Dict[str, Any]],
|
| 318 |
+
) -> None:
|
| 319 |
+
"""Heatmaps go to the END of the board — the showcase tab, appended last.
|
| 320 |
+
|
| 321 |
+
Run this AFTER _fill_showcase so heatmaps sit behind the custom charts and
|
| 322 |
+
the table rather than leading the tab.
|
| 323 |
+
"""
|
| 324 |
+
heatmap_ids = [
|
| 325 |
+
viz.get("id", "")
|
| 326 |
+
for viz in visualizations
|
| 327 |
+
if _chart_type(viz) == "HEATMAP" and viz.get("id")
|
| 328 |
+
]
|
| 329 |
+
if not heatmap_ids:
|
| 330 |
return
|
| 331 |
+
heatmap_set = set(heatmap_ids)
|
| 332 |
+
for role in ("geo", "trend", "driver", "breakdown", "ranking", "detail", "showcase"):
|
| 333 |
+
buckets[role] = [i for i in buckets[role] if i not in heatmap_set]
|
| 334 |
+
buckets["showcase"].extend(heatmap_ids)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _fill_showcase(
|
| 338 |
+
buckets: Dict[str, List[str]],
|
| 339 |
+
visualizations: List[Dict[str, Any]],
|
| 340 |
+
) -> None:
|
| 341 |
+
"""Populate the showcase tab: custom/exotic charts first, then exactly ONE
|
| 342 |
+
table, then whatever is left over from the manager tab.
|
| 343 |
+
|
| 344 |
+
Tables are banned everywhere else on the board; the showcase tab is the
|
| 345 |
+
single sanctioned exception, and it gets one.
|
| 346 |
+
"""
|
| 347 |
+
by_id = {viz.get("id"): viz for viz in visualizations if viz.get("id")}
|
| 348 |
+
other_roles = ("breakdown", "ranking", "detail", "driver", "trend")
|
| 349 |
+
|
| 350 |
+
def take(predicate, limit) -> None:
|
| 351 |
+
for role in other_roles:
|
| 352 |
+
for viz_id in list(buckets[role]):
|
| 353 |
+
if len(buckets["showcase"]) >= limit:
|
| 354 |
+
return
|
| 355 |
+
viz = by_id.get(viz_id)
|
| 356 |
+
if viz is not None and predicate(viz):
|
| 357 |
+
buckets[role].remove(viz_id)
|
| 358 |
+
buckets["showcase"].append(viz_id)
|
| 359 |
+
|
| 360 |
+
# 1. Custom and exotic visuals — the reason this tab exists.
|
| 361 |
+
take(lambda v: _chart_type(v) in SHOWCASE_CHART_TYPES, SHOWCASE_TAB_TARGET)
|
| 362 |
+
|
| 363 |
+
# 2. Exactly one table.
|
| 364 |
+
if not any(_is_table_viz(by_id[i]) for i in buckets["showcase"] if i in by_id):
|
| 365 |
+
take(_is_table_viz, len(buckets["showcase"]) + 1)
|
| 366 |
+
|
| 367 |
+
# 3. Any remaining tables go nowhere near the exec/manager tabs.
|
| 368 |
+
for role in other_roles:
|
| 369 |
+
keep = []
|
| 370 |
+
for viz_id in buckets[role]:
|
| 371 |
+
viz = by_id.get(viz_id)
|
| 372 |
+
if viz is not None and _is_table_viz(viz):
|
| 373 |
+
buckets["showcase"].append(viz_id)
|
| 374 |
+
else:
|
| 375 |
+
keep.append(viz_id)
|
| 376 |
+
buckets[role] = keep
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _rebalance_tabs(buckets: Dict[str, List[str]]) -> None:
|
| 380 |
+
"""Split analysis charts across the executive and manager tabs.
|
| 381 |
+
|
| 382 |
+
Retuned ONCE for three tabs (handoff 2026-09-02, item 3). The showcase tab
|
| 383 |
+
has already taken its charts by the time this runs, so `chart_total` is what
|
| 384 |
+
the first two tabs share.
|
| 385 |
+
|
| 386 |
+
Priorities, in order:
|
| 387 |
+
1. The manager tab gets at least MANAGER_MIN_TILES (3). A one-viz manager
|
| 388 |
+
tab is a failure the SEs called out; "keep that one and add two".
|
| 389 |
+
2. The executive tab keeps at least one analysis chart under its KPIs.
|
| 390 |
+
3. Beyond that, the exec tab is front-loaded: it takes everything past
|
| 391 |
+
MANAGER_TAB_TARGET, and the manager tab does not grow past it.
|
| 392 |
+
|
| 393 |
+
Worked examples (analysis charts only; KPIs and showcase excluded):
|
| 394 |
+
12 charts -> exec 6, manager 6
|
| 395 |
+
10 charts -> exec 4, manager 6
|
| 396 |
+
6 charts -> exec 3, manager 3
|
| 397 |
+
3 charts -> exec 1, manager 2 (floor yields to rule 2)
|
| 398 |
+
1 chart -> exec 1, manager tab omitted
|
| 399 |
+
"""
|
| 400 |
+
chart_total = sum(len(buckets[r]) for r in _EXEC_ANALYSIS_ROLES + _MANAGER_ROLES)
|
| 401 |
+
if chart_total == 0:
|
| 402 |
return
|
| 403 |
|
| 404 |
+
# Rule 3, then rule 1, then rule 2 caps it.
|
| 405 |
+
manager_target = max(MANAGER_MIN_TILES, chart_total - EXEC_MIN_ANALYSIS)
|
| 406 |
+
manager_target = min(manager_target, MANAGER_TAB_TARGET)
|
| 407 |
+
manager_target = min(manager_target, chart_total - 1) # exec keeps >= 1
|
| 408 |
+
manager_target = max(manager_target, 0)
|
| 409 |
+
|
| 410 |
+
# Too many on the manager tab -> promote to the exec tab's driver section,
|
| 411 |
+
# taking from the END of each bucket so the tiles that most belong on a
|
| 412 |
+
# drilldown (multi-dimensional charts, detail tables) stay put — and never
|
| 413 |
+
# the strongest remaining chart, which sits at the front.
|
| 414 |
+
surplus = sum(len(buckets[r]) for r in _MANAGER_ROLES) - manager_target
|
| 415 |
+
for role in _MANAGER_ROLES:
|
| 416 |
+
while surplus > 0 and buckets[role]:
|
| 417 |
+
buckets["driver"].append(buckets[role].pop(0 if role == "detail" else -1))
|
| 418 |
+
surplus -= 1
|
| 419 |
+
|
| 420 |
+
# Too few -> borrow from the end of the exec analysis roles, but leave the
|
| 421 |
+
# exec tab at least one analysis chart in total.
|
| 422 |
+
deficit = manager_target - sum(len(buckets[r]) for r in _MANAGER_ROLES)
|
| 423 |
+
for role in ("driver", "trend"):
|
| 424 |
+
while deficit > 0 and buckets[role]:
|
| 425 |
+
exec_left = len(buckets["driver"]) + len(buckets["trend"])
|
| 426 |
+
if exec_left <= 1:
|
| 427 |
+
break
|
| 428 |
+
buckets["breakdown"].append(buckets[role].pop())
|
| 429 |
+
deficit -= 1
|
| 430 |
|
| 431 |
|
| 432 |
def _build_tabs(
|
|
|
|
| 437 |
plan = plan or {}
|
| 438 |
executive_tab = _executive_tab_name(plan)
|
| 439 |
manager_tab = _manager_tab_name(plan)
|
| 440 |
+
showcase_tab = _showcase_tab_name(plan)
|
| 441 |
+
|
| 442 |
+
executive_groups = [g for g in groups if g.get("tab") == executive_tab]
|
| 443 |
+
operations_groups = [g for g in groups if g.get("tab") == manager_tab]
|
| 444 |
+
showcase_groups = [g for g in groups if g.get("tab") == showcase_tab]
|
| 445 |
|
| 446 |
if not executive_groups:
|
| 447 |
executive_groups = groups[:1]
|
| 448 |
+
operations_groups = [g for g in groups[1:] if g.get("tab") != showcase_tab]
|
| 449 |
|
| 450 |
+
tabs = [
|
|
|
|
| 451 |
{
|
| 452 |
"name": executive_tab,
|
| 453 |
"description": "Executive-ready performance summary and key drivers",
|
| 454 |
"tiles": _top_level_tiles(executive_groups, note_viz_id=note_viz_id),
|
| 455 |
"group_layouts": [_group_layout(group) for group in executive_groups],
|
| 456 |
}
|
| 457 |
+
]
|
| 458 |
+
for name, description, tab_groups in (
|
| 459 |
+
(manager_tab, "Manager-level breakdowns, rankings, and detail", operations_groups),
|
| 460 |
+
(showcase_tab, "Custom visuals, exploratory charts, and row-level detail",
|
| 461 |
+
showcase_groups),
|
| 462 |
+
):
|
| 463 |
+
if tab_groups:
|
| 464 |
+
tabs.append({
|
| 465 |
+
"name": name,
|
| 466 |
+
"description": description,
|
| 467 |
+
"tiles": _top_level_tiles(tab_groups),
|
| 468 |
+
"group_layouts": [_group_layout(group) for group in tab_groups],
|
| 469 |
+
})
|
| 470 |
return tabs
|
| 471 |
|
| 472 |
|
|
|
|
| 527 |
return {"id": group.get("id", ""), "tiles": tiles}
|
| 528 |
|
| 529 |
|
| 530 |
+
# Golden reference heights: the IoT board's 4-KPI hero row is h=4 (w=3 each) and
|
| 531 |
+
# its chart rows are h=6..8. We were shipping 5/10, which made boards feel loose.
|
| 532 |
+
KPI_ROW_H = 4
|
| 533 |
+
CHART_ROW_H = 8
|
| 534 |
+
KPI_STANDARD_COUNT = 4
|
| 535 |
|
| 536 |
|
| 537 |
def _chart_group_widths(n: int) -> List[int]:
|
|
|
|
| 567 |
{
|
| 568 |
"lb_border_type": style_config.get("border", "CURVED"),
|
| 569 |
"lb_brand_color": style_config.get("brand_color", "LBC_A"),
|
| 570 |
+
# Verbatim from goldendemo/Vizio.com + Manufacturing_IoT (both agree).
|
| 571 |
"hide_group_title": "false",
|
| 572 |
+
"hide_group_description": "false",
|
| 573 |
"hide_group_tile_description": "false",
|
| 574 |
+
"hide_tile_description": "true",
|
| 575 |
+
"kpi_hero_font_size": "M",
|
| 576 |
},
|
| 577 |
)
|
| 578 |
|
|
|
|
| 671 |
)
|
| 672 |
|
| 673 |
|
| 674 |
+
HEATMAP_LIMIT = 1 # heatmaps read as a wall of green; keep at most this many
|
| 675 |
+
|
| 676 |
+
|
| 677 |
def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> str:
|
| 678 |
"""Keep heatmaps rare and add varied chart colors to avoid all-blue boards."""
|
| 679 |
heatmaps_seen = 0
|
|
|
|
| 687 |
|
| 688 |
if chart_type == "HEATMAP":
|
| 689 |
heatmaps_seen += 1
|
| 690 |
+
if heatmaps_seen > HEATMAP_LIMIT:
|
| 691 |
chart["type"] = _replacement_chart_type(answer)
|
| 692 |
chart_type = chart["type"]
|
| 693 |
heatmaps_converted += 1
|
|
|
|
| 704 |
return "; ".join(parts)
|
| 705 |
|
| 706 |
|
| 707 |
+
# Chart types we know render correctly from the specs we produce. Anything else
|
| 708 |
+
# is a chart we cannot vouch for, so it goes to the showcase tab rather than in
|
| 709 |
+
# front of an executive.
|
| 710 |
+
KNOWN_GOOD_CHART_TYPES = frozenset({
|
| 711 |
+
"KPI", "LINE", "AREA", "COLUMN", "BAR", "PIE", "HEATMAP",
|
| 712 |
+
"STACKED_COLUMN", "STACKED_BAR", "STACKED_AREA", "LINE_COLUMN",
|
| 713 |
+
"ADVANCED_LINE", "ADVANCED_AREA", "ADVANCED_COLUMN", "ADVANCED_BAR",
|
| 714 |
+
"ADVANCED_STACKED_COLUMN", "ADVANCED_STACKED_BAR", "ADVANCED_STACKED_AREA",
|
| 715 |
+
"ADVANCED_LINE_COLUMN", "PIVOT_TABLE", "TABLE", "GRID_TABLE",
|
| 716 |
+
"GEO_AREA", "GEO_BUBBLE", "MUZE_STUDIO",
|
| 717 |
+
})
|
| 718 |
+
|
| 719 |
+
MUZE_SHOWCASE_MAX = 2 # how many surplus heatmaps to upgrade rather than downgrade
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def is_problem_viz(viz: Dict[str, Any]) -> str:
|
| 723 |
+
"""Return a reason string when a viz is one we cannot vouch for, else "".
|
| 724 |
+
|
| 725 |
+
Rather than shipping a doubtful tile on the executive tab, move it to the
|
| 726 |
+
showcase tab where it is clearly experimental. boone's framing: "if we are
|
| 727 |
+
having trouble with a viz let's move it to this tab".
|
| 728 |
+
"""
|
| 729 |
+
answer = viz.get("answer") or {}
|
| 730 |
+
chart = answer.get("chart") or {}
|
| 731 |
+
chart_type = str(chart.get("type") or "")
|
| 732 |
+
|
| 733 |
+
if not chart_type:
|
| 734 |
+
return "no chart type"
|
| 735 |
+
if chart_type not in KNOWN_GOOD_CHART_TYPES:
|
| 736 |
+
return f"unrecognised chart type {chart_type}"
|
| 737 |
+
# Still contradictory after the repair pass ran.
|
| 738 |
+
if chart_type in SINGLE_AXIS_CHART_TYPES and _has_secondary_axis(answer):
|
| 739 |
+
return "chart type cannot render its secondary axis"
|
| 740 |
+
# Deliberately NOT checking column counts: MCP legitimately leaves
|
| 741 |
+
# answer_columns/chart_columns empty on exports that render fine, so that
|
| 742 |
+
# signal quarantines whole boards. Only quarantine what we can prove is bad.
|
| 743 |
+
return ""
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _muze_upgrade_surplus_heatmaps(visualizations: List[Dict[str, Any]]) -> str:
|
| 747 |
+
"""Turn surplus heatmaps into designed Muze cell heatmaps instead of
|
| 748 |
+
downgrading them all to plain columns.
|
| 749 |
+
|
| 750 |
+
The dim x dim -> measure shape is exactly what the Muze cell-heatmap
|
| 751 |
+
archetype draws, so this upgrades the tile boone objected to rather than
|
| 752 |
+
flattening it. Capped at MUZE_SHOWCASE_MAX because these are experimental
|
| 753 |
+
and land on the showcase tab.
|
| 754 |
+
"""
|
| 755 |
+
from demoprep_app.liveboards.muze_templates import (
|
| 756 |
+
CELL_HEATMAP, build_custom_visual_props,
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
upgraded = 0
|
| 760 |
+
seen = 0
|
| 761 |
+
for viz in visualizations:
|
| 762 |
+
answer = viz.get("answer") or {}
|
| 763 |
+
chart = answer.get("chart") or {}
|
| 764 |
+
if chart.get("type") != "HEATMAP":
|
| 765 |
+
continue
|
| 766 |
+
seen += 1
|
| 767 |
+
if seen <= HEATMAP_LIMIT or upgraded >= MUZE_SHOWCASE_MAX:
|
| 768 |
+
continue
|
| 769 |
+
|
| 770 |
+
columns = _answer_column_names(answer) or _chart_column_names(chart)
|
| 771 |
+
measures = [c for c in columns if _is_measure_name(c)]
|
| 772 |
+
dimensions = [c for c in columns
|
| 773 |
+
if not _is_measure_name(c) and not _looks_like_date_column(c)]
|
| 774 |
+
if len(measures) != 1 or len(dimensions) < 2:
|
| 775 |
+
continue
|
| 776 |
+
bindings = {"x": dimensions[0], "y": dimensions[1], "measure": measures[0]}
|
| 777 |
+
try:
|
| 778 |
+
props = build_custom_visual_props(CELL_HEATMAP, bindings)
|
| 779 |
+
except ValueError:
|
| 780 |
+
continue # e.g. a column name carrying a quote
|
| 781 |
+
|
| 782 |
+
chart["type"] = "MUZE_STUDIO"
|
| 783 |
+
chart["custom_visual_props"] = props
|
| 784 |
+
# The Muze program draws its own SVG; a stale axis config would only
|
| 785 |
+
# contradict it.
|
| 786 |
+
chart.pop("axis_configs", None)
|
| 787 |
+
upgraded += 1
|
| 788 |
+
|
| 789 |
+
if upgraded:
|
| 790 |
+
return f"Upgraded {upgraded} surplus heatmap(s) to Muze cell heatmaps"
|
| 791 |
+
return ""
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
def _quarantine_problem_vizzes(
|
| 795 |
+
buckets: Dict[str, List[str]],
|
| 796 |
+
visualizations: List[Dict[str, Any]],
|
| 797 |
+
) -> str:
|
| 798 |
+
"""Move doubtful visualizations to the showcase tab, with the reason logged."""
|
| 799 |
+
by_id = {v.get("id"): v for v in visualizations if v.get("id")}
|
| 800 |
+
moved = []
|
| 801 |
+
for role in ("geo", "trend", "driver", "breakdown", "ranking", "detail"):
|
| 802 |
+
keep = []
|
| 803 |
+
for viz_id in buckets[role]:
|
| 804 |
+
reason = is_problem_viz(by_id.get(viz_id, {}))
|
| 805 |
+
if reason:
|
| 806 |
+
buckets["showcase"].append(viz_id)
|
| 807 |
+
moved.append(f"{viz_id} ({reason})")
|
| 808 |
+
else:
|
| 809 |
+
keep.append(viz_id)
|
| 810 |
+
buckets[role] = keep
|
| 811 |
+
if not moved:
|
| 812 |
+
return ""
|
| 813 |
+
return "Quarantined to the showcase tab: " + "; ".join(moved)
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def repair_secondary_axis_mismatch(visualizations: List[Dict[str, Any]]) -> str:
|
| 817 |
+
"""Give every viz a chart type its OWN axis config allows — not just demoted heatmaps.
|
| 818 |
+
|
| 819 |
+
e93e261 fixed this only where WE overwrite chart["type"] (the heatmap cap).
|
| 820 |
+
But Spotter/MCP hands us two-measure "X vs Y" answers that ALREADY arrive as
|
| 821 |
+
ADVANCED_COLUMN / ADVANCED_BAR while their client_state_v2 declares a
|
| 822 |
+
secondary Y axis (isOpposite: true). Those are the majority, and they were
|
| 823 |
+
never touched — measured on boards built 2026-09-01:
|
| 824 |
+
|
| 825 |
+
198e8478 (secloud, mani.uppala) Viz_7, Viz_9, Viz_10, Viz_11
|
| 826 |
+
25f8b739 (sebe, boone) Viz_6, Viz_9, Viz_10
|
| 827 |
+
2c3fc690 (sebe) Viz_5, Viz_6, Viz_8
|
| 828 |
+
|
| 829 |
+
Every one is an "X vs Y" / "X and Y" two-measure comparison, and every one
|
| 830 |
+
rendered "Cannot display the chart".
|
| 831 |
+
|
| 832 |
+
Repair chosen: promote the TYPE to the combo chart, not strip the axis.
|
| 833 |
+
Stripping isOpposite also produces a valid spec, but it silently discards
|
| 834 |
+
the dual-scale intent and puts a $100M bar next to a 0.5 rate bar. The
|
| 835 |
+
promotion has direct rendering evidence behind it — on Larry's board
|
| 836 |
+
(eb6f9238) Viz_6 and Viz_12 are the SAME question with identical
|
| 837 |
+
axisProperties; the ADVANCED_COLUMN one blanked and the ADVANCED_LINE_COLUMN
|
| 838 |
+
one rendered.
|
| 839 |
+
|
| 840 |
+
Idempotent, and safe to run after every other chart-type mutation.
|
| 841 |
+
|
| 842 |
+
NOTE (golden-demo-v3): this is the MCP line's function (913c157), adopted
|
| 843 |
+
verbatim so the merge carries exactly ONE blank-tile repair. This branch's
|
| 844 |
+
own equivalent (2e3a32e) was deleted in its favour.
|
| 845 |
+
"""
|
| 846 |
+
repaired: List[str] = []
|
| 847 |
+
for viz in visualizations:
|
| 848 |
+
if "note_tile" in viz:
|
| 849 |
+
continue
|
| 850 |
+
answer = viz.get("answer") or {}
|
| 851 |
+
chart = answer.get("chart")
|
| 852 |
+
if not isinstance(chart, dict):
|
| 853 |
+
continue
|
| 854 |
+
if str(chart.get("type", "")).upper() not in SINGLE_AXIS_CHART_TYPES:
|
| 855 |
+
continue
|
| 856 |
+
if not _has_secondary_axis(answer):
|
| 857 |
+
continue
|
| 858 |
+
chart["type"] = COMBO_CHART_TYPE
|
| 859 |
+
repaired.append(viz.get("id") or answer.get("name", "?"))
|
| 860 |
+
|
| 861 |
+
if not repaired:
|
| 862 |
+
return ""
|
| 863 |
+
return (
|
| 864 |
+
f"Repaired {len(repaired)} chart(s) whose type could not render their "
|
| 865 |
+
f"secondary axis ({', '.join(repaired)} -> {COMBO_CHART_TYPE})"
|
| 866 |
+
)
|
| 867 |
+
|
| 868 |
+
|
| 869 |
def _force_chart_mode(visualizations: List[Dict[str, Any]]) -> str:
|
| 870 |
"""Charts rendering as data grids (display_mode=TABLE_MODE) but with a real
|
| 871 |
chart type should display as charts. Genuine tables/KPIs are left alone."""
|
| 872 |
fixed = 0
|
| 873 |
for viz in visualizations:
|
| 874 |
chart_type = _chart_type(viz)
|
| 875 |
+
if chart_type in {"TABLE", "GRID_TABLE", "PIVOT_TABLE", "KPI", "NOTE", "UNKNOWN"}:
|
| 876 |
continue
|
| 877 |
answer = viz.get("answer", {})
|
| 878 |
if answer.get("display_mode") != "CHART_MODE":
|
|
|
|
| 932 |
)
|
| 933 |
|
| 934 |
|
| 935 |
+
_GEO_AREA_PREFERENCE = (
|
| 936 |
+
# Descending preference. STATE FIRST: demo data is overwhelmingly US and a
|
| 937 |
+
# state choropleth is the useful one. A country map of a single-country
|
| 938 |
+
# dataset is one solid blob.
|
| 939 |
+
(("state", "province"), ("estate", "statement", "status")),
|
| 940 |
+
(("county",), ()),
|
| 941 |
+
(("city", "metro"), ()),
|
| 942 |
+
(("country",), ()),
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
def detect_geo_area_column(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None:
|
| 947 |
+
"""Find an area-mappable geo attribute (country preferred) plus a measure.
|
| 948 |
+
|
| 949 |
+
Unlike detect_geo_columns this needs NO lat/lon — GEO_AREA fills regions by
|
| 950 |
+
name. The model column must carry geo_config or the map renders blank
|
| 951 |
+
(see ThoughtSpotDeployer._geo_config_for_column).
|
| 952 |
+
"""
|
| 953 |
+
names = [(c.get("name", ""), str(c.get("type", "")).upper())
|
| 954 |
+
for c in (model_columns or []) if c.get("name")]
|
| 955 |
+
# ThoughtSpot fills regions only for columns tagged with geo_config in the
|
| 956 |
+
# model. A GEO_AREA on an untagged column renders a BLANK map with no error,
|
| 957 |
+
# so an untagged candidate is worse than no map at all. When the caller does
|
| 958 |
+
# not report the flag at all (older callers, tests), fall back to name-based
|
| 959 |
+
# detection rather than silently disabling geo everywhere.
|
| 960 |
+
reports_geo_config = any("geo_config" in c for c in (model_columns or []))
|
| 961 |
+
geo_tagged = {
|
| 962 |
+
c.get("name", "") for c in (model_columns or []) if c.get("geo_config")
|
| 963 |
+
}
|
| 964 |
+
attrs = [
|
| 965 |
+
n for n, t in names
|
| 966 |
+
if t == "ATTRIBUTE" and (not reports_geo_config or n in geo_tagged)
|
| 967 |
+
]
|
| 968 |
+
geodim = None
|
| 969 |
+
for hints, exclusions in _GEO_AREA_PREFERENCE:
|
| 970 |
+
geodim = next(
|
| 971 |
+
(n for n in attrs
|
| 972 |
+
if any(h in n.lower() for h in hints)
|
| 973 |
+
and not any(x in n.lower() for x in exclusions)),
|
| 974 |
+
None,
|
| 975 |
+
)
|
| 976 |
+
if geodim:
|
| 977 |
+
break
|
| 978 |
+
if not geodim:
|
| 979 |
+
return None
|
| 980 |
+
measure = _preferred_measure(names)
|
| 981 |
+
if not measure:
|
| 982 |
+
return None
|
| 983 |
+
return {"geodim": geodim, "measure": measure}
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def _preferred_measure(names: List[Tuple[str, str]]) -> str | None:
|
| 987 |
+
measures = [n for n, t in names if t == "MEASURE"
|
| 988 |
+
and not _LAT_RE.search(n) and not _LON_RE.search(n)]
|
| 989 |
+
return next(
|
| 990 |
+
(n for n in measures if any(k in n.lower() for k in
|
| 991 |
+
("revenue", "sales", "amount", "value", "gmv", "spend"))),
|
| 992 |
+
measures[0] if measures else None,
|
| 993 |
+
)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
def _geo_area_client_state(geodim: str, measure_col: str) -> str:
|
| 997 |
+
"""Golden-demo GEO_AREA state: yellow->red gradient fill with data labels."""
|
| 998 |
+
gradient = ["#ffffb2", "#fddd87", "#fba35d", "#f75534", "#f9140a", "#d70315", "#b10026"]
|
| 999 |
+
state = {
|
| 1000 |
+
"version": "V4DOT2",
|
| 1001 |
+
"chartProperties": {
|
| 1002 |
+
"chartSpecific": {"customProps": "{\"tileType\":\"light-v9\"}"},
|
| 1003 |
+
},
|
| 1004 |
+
"columnProperties": [
|
| 1005 |
+
{"columnId": geodim, "columnProperty": {}},
|
| 1006 |
+
{"columnId": measure_col, "columnProperty": {"dataLabels": True}},
|
| 1007 |
+
],
|
| 1008 |
+
"axisProperties": [
|
| 1009 |
+
{"id": "geo-area-y", "properties": {
|
| 1010 |
+
"axisType": "Y", "linkedColumns": [measure_col], "isOpposite": False}},
|
| 1011 |
+
{"id": "geo-area-x", "properties": {"axisType": "X", "linkedColumns": [geodim]}},
|
| 1012 |
+
],
|
| 1013 |
+
"systemMultiColorSeriesColors": [
|
| 1014 |
+
{"serieName": measure_col,
|
| 1015 |
+
"colorMap": [{"serieName": geodim, "color": gradient}]}
|
| 1016 |
+
],
|
| 1017 |
+
}
|
| 1018 |
+
return json_dumps_compact(state)
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
def _build_geo_area_viz(
|
| 1022 |
+
sibling_answer: Dict[str, Any],
|
| 1023 |
+
geo: Dict[str, str],
|
| 1024 |
+
viz_id: str = "Viz_Geo",
|
| 1025 |
+
) -> Dict[str, Any]:
|
| 1026 |
+
geodim, measure = geo["geodim"], geo["measure"]
|
| 1027 |
+
t_meas = f"Total {measure}"
|
| 1028 |
+
return {
|
| 1029 |
+
"id": viz_id,
|
| 1030 |
+
"answer": {
|
| 1031 |
+
"name": f"{measure} by {geodim}",
|
| 1032 |
+
"tables": deepcopy(sibling_answer.get("tables", [])),
|
| 1033 |
+
"search_query": f"sum [{measure}] [{geodim}]",
|
| 1034 |
+
"answer_columns": [{"name": t_meas}, {"name": geodim}],
|
| 1035 |
+
"chart": {
|
| 1036 |
+
"type": "GEO_AREA",
|
| 1037 |
+
"chart_columns": [{"column_id": t_meas}, {"column_id": geodim}],
|
| 1038 |
+
"axis_configs": [{"x": [geodim], "y": [t_meas]}],
|
| 1039 |
+
"client_state": "",
|
| 1040 |
+
"client_state_v2": _geo_area_client_state(geodim, t_meas),
|
| 1041 |
+
},
|
| 1042 |
+
"display_mode": "CHART_MODE",
|
| 1043 |
+
},
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
def detect_geo_columns(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None:
|
| 1048 |
"""From a model's columns [{name, type}], find lat/lon + a geo attribute + a
|
| 1049 |
measure. Returns {lat, lon, geodim, measure} or None if not geographic."""
|
|
|
|
| 1114 |
}
|
| 1115 |
|
| 1116 |
|
| 1117 |
+
def untagged_geo_candidates(model_columns: List[Dict[str, Any]] | None) -> List[str]:
|
| 1118 |
+
"""Attribute columns that LOOK geographic but carry no geo_config.
|
| 1119 |
+
|
| 1120 |
+
These are why a board has no map. Returned so the caller can say so out loud
|
| 1121 |
+
rather than silently skipping — the model needs the tag, the liveboard can
|
| 1122 |
+
do nothing about it.
|
| 1123 |
+
"""
|
| 1124 |
+
if not model_columns or not any("geo_config" in c for c in model_columns):
|
| 1125 |
+
return []
|
| 1126 |
+
hints = tuple(h for hints, _ in _GEO_AREA_PREFERENCE for h in hints)
|
| 1127 |
+
return [
|
| 1128 |
+
c.get("name", "")
|
| 1129 |
+
for c in model_columns
|
| 1130 |
+
if str(c.get("type", "")).upper() == "ATTRIBUTE"
|
| 1131 |
+
and not c.get("geo_config")
|
| 1132 |
+
and any(h in c.get("name", "").lower() for h in hints)
|
| 1133 |
+
and not any(x in c.get("name", "").lower()
|
| 1134 |
+
for x in ("estate", "statement", "status"))
|
| 1135 |
+
]
|
| 1136 |
+
|
| 1137 |
+
|
| 1138 |
+
# ThoughtSpot's allowed values for answer.chart.type, verbatim from a TML import
|
| 1139 |
+
# rejection (ZZ Lab build6, 2026-09-02). Note: "TABLE" is NOT in it — a table is
|
| 1140 |
+
# a valid chart type plus display_mode: TABLE_MODE. Synthesised vizzes must use
|
| 1141 |
+
# a type from this set or the WHOLE enhancement import is discarded.
|
| 1142 |
+
TS_ALLOWED_CHART_TYPES = frozenset({
|
| 1143 |
+
"ADVANCED_AREA", "ADVANCED_BAR", "ADVANCED_BUBBLE", "ADVANCED_COLUMN",
|
| 1144 |
+
"ADVANCED_DONUT", "ADVANCED_LINE", "ADVANCED_LINE_COLUMN",
|
| 1145 |
+
"ADVANCED_LINE_STACKED_COLUMN", "ADVANCED_PIVOT_TABLE", "ADVANCED_SCATTER",
|
| 1146 |
+
"ADVANCED_STACKED_AREA", "ADVANCED_STACKED_BAR", "ADVANCED_STACKED_COLUMN",
|
| 1147 |
+
"ADVANCED_WATERFALL", "AREA", "BAR", "BUBBLE", "CANDLESTICK", "COLUMN",
|
| 1148 |
+
"CUSTOM_CHART", "FUNNEL", "GEO_AREA", "GEO_BUBBLE", "GEO_EARTH_AREA",
|
| 1149 |
+
"GEO_EARTH_BAR", "GEO_EARTH_BUBBLE", "GEO_EARTH_GRAPH", "GEO_EARTH_HEATMAP",
|
| 1150 |
+
"GEO_HEATMAP", "GRID_TABLE", "HEATMAP", "KPI", "LINE", "LINE_COLUMN",
|
| 1151 |
+
"LINE_STACKED_COLUMN", "MUZE_STUDIO", "PARETO", "PIE", "PIVOT_TABLE",
|
| 1152 |
+
"SANKEY", "SCATTER", "SPIDER_WEB", "STACKED_AREA", "STACKED_BAR",
|
| 1153 |
+
"STACKED_COLUMN", "TREEMAP", "WATERFALL", "WHISKER_SCATTER",
|
| 1154 |
+
})
|
| 1155 |
+
|
| 1156 |
+
# The chart type we give a synthesised detail table. Golden-demo TABLE_MODE
|
| 1157 |
+
# vizzes keep a real chart type; GRID_TABLE is the server's table type and
|
| 1158 |
+
# contains "TABLE", which every table-routing check in this module keys on.
|
| 1159 |
+
DETAIL_TABLE_CHART_TYPE = "GRID_TABLE"
|
| 1160 |
+
|
| 1161 |
+
|
| 1162 |
+
_TABLE_ENTITY_HINTS = (
|
| 1163 |
+
"name", "payer", "customer", "account", "provider", "practice", "store",
|
| 1164 |
+
"product", "rep", "vendor", "supplier", "client", "patient", "employee",
|
| 1165 |
+
)
|
| 1166 |
+
_TABLE_MEASURE_HINTS = (
|
| 1167 |
+
"revenue", "amount", "sales", "collected", "billed", "cost", "margin",
|
| 1168 |
+
"rate", "count", "total", "spend", "value",
|
| 1169 |
+
)
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
+
def detect_detail_table_columns(model_columns: List[Dict[str, Any]] | None) -> Dict[str, Any] | None:
|
| 1173 |
+
"""Pick one entity attribute and up to three measures for a detail table.
|
| 1174 |
+
|
| 1175 |
+
Prefers an entity-like attribute (a "Name" column, payer, provider...) over a
|
| 1176 |
+
low-cardinality category, and business measures over ids. Returns
|
| 1177 |
+
{"entity": str, "measures": [str, ...]} or None when the model cannot support
|
| 1178 |
+
a useful table.
|
| 1179 |
+
"""
|
| 1180 |
+
cols = [(c.get("name", ""), str(c.get("type", "")).upper())
|
| 1181 |
+
for c in (model_columns or []) if c.get("name")]
|
| 1182 |
+
attrs = [n for n, t in cols if t == "ATTRIBUTE" and not _looks_like_date_column(n)]
|
| 1183 |
+
measures = [n for n, t in cols if t == "MEASURE"
|
| 1184 |
+
and not _LAT_RE.search(n) and not _LON_RE.search(n)]
|
| 1185 |
+
if not attrs or not measures:
|
| 1186 |
+
return None
|
| 1187 |
+
|
| 1188 |
+
def attr_score(name: str) -> int:
|
| 1189 |
+
low = name.lower()
|
| 1190 |
+
return sum(10 for h in _TABLE_ENTITY_HINTS if h in low) + (5 if "name" in low else 0)
|
| 1191 |
+
|
| 1192 |
+
def measure_score(name: str) -> int:
|
| 1193 |
+
low = name.lower()
|
| 1194 |
+
return sum(10 for h in _TABLE_MEASURE_HINTS if h in low)
|
| 1195 |
+
|
| 1196 |
+
entity = max(attrs, key=attr_score)
|
| 1197 |
+
if attr_score(entity) == 0:
|
| 1198 |
+
return None # nothing entity-like; a table of categories is not a drill-down
|
| 1199 |
+
ranked = sorted(measures, key=measure_score, reverse=True)
|
| 1200 |
+
return {"entity": entity, "measures": ranked[:3]}
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
+
def _build_detail_table_viz(
|
| 1204 |
+
sibling_answer: Dict[str, Any],
|
| 1205 |
+
spec: Dict[str, Any],
|
| 1206 |
+
viz_id: str = "Viz_Detail",
|
| 1207 |
+
) -> Dict[str, Any]:
|
| 1208 |
+
entity, measures = spec["entity"], spec["measures"]
|
| 1209 |
+
totals = [f"Total {m}" for m in measures]
|
| 1210 |
+
query = f"[{entity}] " + " ".join(f"[{m}]" for m in measures)
|
| 1211 |
+
return {
|
| 1212 |
+
"id": viz_id,
|
| 1213 |
+
"answer": {
|
| 1214 |
+
"name": f"{entity} Detail",
|
| 1215 |
+
"tables": deepcopy(sibling_answer.get("tables", [])),
|
| 1216 |
+
"search_query": query,
|
| 1217 |
+
"answer_columns": [{"name": entity}] + [{"name": t} for t in totals],
|
| 1218 |
+
"table": {
|
| 1219 |
+
"table_columns": [{"column_id": entity}] + [{"column_id": t} for t in totals],
|
| 1220 |
+
"ordered_column_ids": [entity] + totals,
|
| 1221 |
+
"client_state": "",
|
| 1222 |
+
"client_state_v2": '{"tableVizPropVersion": "V1","wrapTableHeader": true}',
|
| 1223 |
+
},
|
| 1224 |
+
"chart": {
|
| 1225 |
+
"type": DETAIL_TABLE_CHART_TYPE,
|
| 1226 |
+
"chart_columns": [{"column_id": entity}] + [{"column_id": t} for t in totals],
|
| 1227 |
+
},
|
| 1228 |
+
"display_mode": "TABLE_MODE",
|
| 1229 |
+
},
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
+
def _maybe_build_detail_table(
|
| 1234 |
+
visualizations: List[Dict[str, Any]],
|
| 1235 |
+
model_columns: List[Dict[str, Any]] | None,
|
| 1236 |
+
) -> Dict[str, Any] | None:
|
| 1237 |
+
"""Synthesise the one sanctioned table when the board has none.
|
| 1238 |
+
|
| 1239 |
+
boone asked for exactly one table chart on the third page. Asking the
|
| 1240 |
+
question generator for it did not work (build4/build5 produced none — a
|
| 1241 |
+
prompt request is advisory), so the table is built deterministically here,
|
| 1242 |
+
the same way the geo map is. _fill_showcase routes it to the showcase tab.
|
| 1243 |
+
"""
|
| 1244 |
+
if not model_columns:
|
| 1245 |
+
return None
|
| 1246 |
+
if any("TABLE" in str((v.get("answer") or {}).get("chart", {}).get("type", ""))
|
| 1247 |
+
for v in visualizations if "note_tile" not in v):
|
| 1248 |
+
return None
|
| 1249 |
+
spec = detect_detail_table_columns(model_columns)
|
| 1250 |
+
if not spec:
|
| 1251 |
+
return None
|
| 1252 |
+
sibling = next((v.get("answer", {}) for v in visualizations
|
| 1253 |
+
if "note_tile" not in v and v.get("answer", {}).get("tables")), None)
|
| 1254 |
+
if not sibling:
|
| 1255 |
+
return None
|
| 1256 |
+
return _build_detail_table_viz(sibling, spec)
|
| 1257 |
+
|
| 1258 |
+
|
| 1259 |
def _maybe_build_geo_viz(
|
| 1260 |
visualizations: List[Dict[str, Any]],
|
| 1261 |
model_columns: List[Dict[str, Any]] | None,
|
|
|
|
| 1266 |
if any(str(v.get("answer", {}).get("chart", {}).get("type", "")).startswith("GEO")
|
| 1267 |
for v in visualizations if "note_tile" not in v):
|
| 1268 |
return None
|
|
|
|
|
|
|
|
|
|
| 1269 |
sibling = next((v.get("answer", {}) for v in visualizations
|
| 1270 |
if "note_tile" not in v and v.get("answer", {}).get("tables")), None)
|
| 1271 |
if not sibling:
|
| 1272 |
return None
|
| 1273 |
+
# Prefer a filled country/region map (golden-demo style); fall back to
|
| 1274 |
+
# lat/lon bubbles only when no area-mappable attribute exists.
|
| 1275 |
+
area_geo = detect_geo_area_column(model_columns)
|
| 1276 |
+
if area_geo:
|
| 1277 |
+
return _build_geo_area_viz(sibling, area_geo)
|
| 1278 |
+
geo = detect_geo_columns(model_columns)
|
| 1279 |
+
if not geo:
|
| 1280 |
+
return None
|
| 1281 |
return _build_geo_viz(sibling, geo)
|
| 1282 |
|
| 1283 |
|
|
|
|
| 1337 |
{"column_id": date_column},
|
| 1338 |
{"column_id": measure_column},
|
| 1339 |
]
|
| 1340 |
+
# Previously this popped client_state_v2, which silently removed the only
|
| 1341 |
+
# place sparkline/comparison settings can live (root cause of TASK 3:
|
| 1342 |
+
# 0 of 18 graded boards had any sparkline state).
|
| 1343 |
+
_apply_kpi_client_state(chart, date_column, measure_column)
|
| 1344 |
converted += 1
|
| 1345 |
|
| 1346 |
if converted:
|
|
|
|
| 1348 |
return ""
|
| 1349 |
|
| 1350 |
|
| 1351 |
+
# Golden-demo KPI customProps. showComparison + showSparkline are what render the
|
| 1352 |
+
# trend line and the percent-change range chip; without them a KPI is a bare number.
|
| 1353 |
+
# PREV_AVAILABLE compares against the previous populated period, which is resilient
|
| 1354 |
+
# to demo data that ends mid-period.
|
| 1355 |
+
def _kpi_custom_props() -> Dict[str, Any]:
|
| 1356 |
+
return {
|
| 1357 |
+
"showLabel": True,
|
| 1358 |
+
"showComparison": True,
|
| 1359 |
+
"showSparkline": True,
|
| 1360 |
+
"showAnomalies": True,
|
| 1361 |
+
"showBounds": True,
|
| 1362 |
+
"customCompare": "PREV_AVAILABLE",
|
| 1363 |
+
"showOnlyLatestAnomaly": False,
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
|
| 1367 |
+
def _apply_kpi_client_state(
|
| 1368 |
+
chart: Dict[str, Any],
|
| 1369 |
+
date_column: str | None,
|
| 1370 |
+
measure_column: str | None,
|
| 1371 |
+
) -> None:
|
| 1372 |
+
"""Write sparkline + comparison state onto a KPI chart, preserving whatever
|
| 1373 |
+
else the export already carried."""
|
| 1374 |
+
client_state = _load_chart_client_state(chart)
|
| 1375 |
+
chart_properties = client_state.setdefault("chartProperties", {})
|
| 1376 |
+
chart_properties["responsiveLayoutPreference"] = "AUTO_ON"
|
| 1377 |
+
chart_specific = chart_properties.setdefault("chartSpecific", {})
|
| 1378 |
+
chart_specific["customProps"] = json_dumps_compact(_kpi_custom_props())
|
| 1379 |
+
|
| 1380 |
+
if date_column and measure_column:
|
| 1381 |
+
client_state["columnProperties"] = [
|
| 1382 |
+
{"columnId": measure_column, "columnProperty": {"conditionalFormatting": {}}},
|
| 1383 |
+
{"columnId": date_column, "columnProperty": {}},
|
| 1384 |
+
]
|
| 1385 |
+
client_state["axisProperties"] = [
|
| 1386 |
+
{"id": "kpi-y", "properties": {
|
| 1387 |
+
"axisType": "Y", "linkedColumns": [measure_column], "isOpposite": False}},
|
| 1388 |
+
{"id": "kpi-x", "properties": {"axisType": "X", "linkedColumns": [date_column]}},
|
| 1389 |
+
]
|
| 1390 |
+
chart["client_state_v2"] = json_dumps_compact(client_state)
|
| 1391 |
+
|
| 1392 |
+
|
| 1393 |
+
def _fix_kpi_sparklines(visualizations: List[Dict[str, Any]]) -> str:
|
| 1394 |
+
"""Ensure EVERY KPI tile carries sparkline/comparison state.
|
| 1395 |
+
|
| 1396 |
+
_convert_periodic_metric_charts only touches answers it promotes to KPI;
|
| 1397 |
+
KPIs that MCP already created never passed through it, which is why graded
|
| 1398 |
+
boards showed bare numbers. A KPI with no date column gets the customProps
|
| 1399 |
+
anyway — the comparison chip still renders; only the sparkline needs time.
|
| 1400 |
+
"""
|
| 1401 |
+
fixed = 0
|
| 1402 |
+
for viz in visualizations:
|
| 1403 |
+
chart = viz.get("answer", {}).get("chart", {})
|
| 1404 |
+
if chart.get("type") != "KPI":
|
| 1405 |
+
continue
|
| 1406 |
+
if _kpi_state_is_complete(chart):
|
| 1407 |
+
continue
|
| 1408 |
+
date_column, measure_column = _kpi_axis_columns(viz.get("answer", {}))
|
| 1409 |
+
_apply_kpi_client_state(chart, date_column, measure_column)
|
| 1410 |
+
fixed += 1
|
| 1411 |
+
return f"Added sparkline/comparison state to {fixed} KPI(s)" if fixed else ""
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
def _kpi_state_is_complete(chart: Dict[str, Any]) -> bool:
|
| 1415 |
+
raw = chart.get("client_state_v2") or ""
|
| 1416 |
+
return "showSparkline" in raw and "showComparison" in raw
|
| 1417 |
+
|
| 1418 |
+
|
| 1419 |
def _kpi_axis_columns(answer: Dict[str, Any]) -> Tuple[str | None, str | None]:
|
| 1420 |
columns = _answer_column_names(answer)
|
| 1421 |
if len(columns) < 2:
|
|
|
|
| 1472 |
)
|
| 1473 |
|
| 1474 |
|
| 1475 |
+
# Chart types that render a SINGLE Y axis. Assigning one of these to an answer
|
| 1476 |
+
# whose saved axis config declares a secondary axis (isOpposite: true) produces a
|
| 1477 |
+
# spec ThoughtSpot refuses to render — the tile shows "Cannot display the chart".
|
| 1478 |
+
SINGLE_AXIS_CHART_TYPES = frozenset({
|
| 1479 |
+
"ADVANCED_COLUMN", "ADVANCED_BAR",
|
| 1480 |
+
"ADVANCED_STACKED_COLUMN", "ADVANCED_STACKED_BAR",
|
| 1481 |
+
})
|
| 1482 |
+
|
| 1483 |
+
# The combo type: bars for the money measure, line for the rate. Supports a
|
| 1484 |
+
# secondary axis, and is what ThoughtSpot's own Search picks for two measures on
|
| 1485 |
+
# different scales.
|
| 1486 |
+
COMBO_CHART_TYPE = "ADVANCED_LINE_COLUMN"
|
| 1487 |
+
|
| 1488 |
+
|
| 1489 |
+
def _has_secondary_axis(answer: Dict[str, Any]) -> bool:
|
| 1490 |
+
"""True when this answer's saved axis config declares a secondary Y axis.
|
| 1491 |
+
|
| 1492 |
+
Two measures on different scales (revenue vs. a rate) get an opposite axis.
|
| 1493 |
+
The flag can sit directly on an axisProperties entry or nested under its
|
| 1494 |
+
"properties" object depending on who wrote the client state, so this walks
|
| 1495 |
+
the decoded structure rather than assuming one shape.
|
| 1496 |
+
"""
|
| 1497 |
+
state = _load_chart_client_state(answer.get("chart", {}) or {})
|
| 1498 |
+
|
| 1499 |
+
def walk(node: Any) -> bool:
|
| 1500 |
+
if isinstance(node, dict):
|
| 1501 |
+
if node.get("isOpposite") is True:
|
| 1502 |
+
return True
|
| 1503 |
+
return any(walk(v) for v in node.values())
|
| 1504 |
+
if isinstance(node, list):
|
| 1505 |
+
return any(walk(v) for v in node)
|
| 1506 |
+
return False
|
| 1507 |
+
|
| 1508 |
+
return walk(state)
|
| 1509 |
+
|
| 1510 |
+
|
| 1511 |
def _replacement_chart_type(answer: Dict[str, Any]) -> str:
|
| 1512 |
+
"""Pick a chart type for a demoted heatmap that is VALID for the axis config
|
| 1513 |
+
already attached to the answer.
|
| 1514 |
+
|
| 1515 |
+
The heatmap cap overwrites chart["type"] but leaves client_state_v2 alone.
|
| 1516 |
+
When that state declares a secondary axis, every single-axis type is an
|
| 1517 |
+
invalid spec — this is what blanked ~9 tiles across 4 customer boards
|
| 1518 |
+
(dev_notes/fix_handoff_2026_09_01.md TASK 1). Larry's board proves it:
|
| 1519 |
+
Viz_6 and Viz_12 are the same question with identical axisProperties; Viz_6
|
| 1520 |
+
was overwritten to ADVANCED_COLUMN and went blank, Viz_12 kept
|
| 1521 |
+
ADVANCED_LINE_COLUMN and renders.
|
| 1522 |
+
|
| 1523 |
+
Returning the combo type keeps the dual-scale intent. Stripping the
|
| 1524 |
+
secondary axis instead would "work" but puts a $100M bar next to a 0.5 bar.
|
| 1525 |
+
"""
|
| 1526 |
+
if _has_secondary_axis(answer):
|
| 1527 |
+
return COMBO_CHART_TYPE
|
| 1528 |
+
|
| 1529 |
name = answer.get("name", "").lower()
|
| 1530 |
query = answer.get("search_query", "").lower()
|
| 1531 |
text = f"{name} {query}"
|
|
|
|
| 1643 |
return plan.get("executive_tab") or EXECUTIVE_TAB
|
| 1644 |
|
| 1645 |
|
| 1646 |
+
def _showcase_tab_name(plan: Dict[str, Any]) -> str:
|
| 1647 |
+
return plan.get("showcase_tab") or SHOWCASE_TAB
|
| 1648 |
+
|
| 1649 |
+
|
| 1650 |
def _manager_tab_name(plan: Dict[str, Any]) -> str:
|
| 1651 |
return plan.get("manager_tab") or OPERATIONS_TAB
|
| 1652 |
|
|
|
|
| 1719 |
return "geo"
|
| 1720 |
if "TABLE" in chart_type:
|
| 1721 |
return "detail"
|
| 1722 |
+
# A single-metric periodic question is a KPI regardless of the chart type MCP
|
| 1723 |
+
# happened to draw it with. Keying off LINE/AREA alone lost the 4th KPI on the
|
| 1724 |
+
# AdvancedMD board: "Net Collection Rate Quarterly" is one measure over one
|
| 1725 |
+
# date column, but MCP rendered it as ADVANCED_COLUMN so it was filed as a
|
| 1726 |
+
# trend and the board shipped 3 KPIs instead of the standard 4.
|
| 1727 |
+
#
|
| 1728 |
+
# The two guards that keep real charts out: no " by " (a breakdown), and at
|
| 1729 |
+
# most one measure (two measures is a comparison chart, not a KPI).
|
| 1730 |
if (
|
| 1731 |
+
chart_type not in {"TABLE", "PIVOT_TABLE", "NOTE"}
|
| 1732 |
and any(period in text for period in ("weekly", "monthly", "quarterly", "daily", "yearly"))
|
| 1733 |
and " by " not in text
|
| 1734 |
+
and len([c for c in _answer_column_names(viz.get("answer", {})) if _is_measure_name(c)]) <= 1
|
| 1735 |
):
|
| 1736 |
return "kpi"
|
| 1737 |
# Rankings before breakdowns: "Top 10 X by Y" is a ranking, not a plain breakdown.
|
demoprep_app/liveboards/muze_templates.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Templated ThoughtSpot custom charts (MUZE_STUDIO / BYOC).
|
| 2 |
+
|
| 3 |
+
A MUZE_STUDIO visualization carries hand-authored JS + CSS + HTML, base64'd
|
| 4 |
+
inside ``answer.chart.custom_visual_props``. The programs are parameterised by a
|
| 5 |
+
handful of column-name constants, so they can be retargeted at a new model by
|
| 6 |
+
substitution — that is what makes them templatable at all.
|
| 7 |
+
|
| 8 |
+
**Why these are not copied verbatim from the reference board.** The four
|
| 9 |
+
MUZE_STUDIO charts in ``goldendemo/Manufacturing_IoT`` each violate several
|
| 10 |
+
documented BYOC hard rules, every one of which fails *silently* as a blank tile:
|
| 11 |
+
|
| 12 |
+
* no ``try``/``catch`` — the BYOC sandbox replaces the real error with
|
| 13 |
+
"Something went wrong", so there is nothing to debug;
|
| 14 |
+
* ``emitRenderCompletedEvent()`` on the success path only — a tile that throws
|
| 15 |
+
never reports in, which hangs Liveboard PDF export for the **whole board**;
|
| 16 |
+
* an unbounded CDN ``<script>`` load with no timeout and (mostly) no fallback
|
| 17 |
+
host — a request that hangs fires neither ``onload`` nor ``onerror``, so the
|
| 18 |
+
awaited promise never settles and the tile renders blank;
|
| 19 |
+
* three of the four omit ``html, body { height: 100% }``, so a percentage-height
|
| 20 |
+
stage can compute to 0px and the tile is blank while the console stays clean.
|
| 21 |
+
|
| 22 |
+
The templates below take the *shape* of the reference charts and none of those
|
| 23 |
+
bugs. The CDN dependency is removed outright — these draw plain SVG rather than
|
| 24 |
+
pulling d3 — which deletes that entire failure class instead of bounding it.
|
| 25 |
+
|
| 26 |
+
Reference: BibekTS/Thoughtspot-Amuzing-chart-skills (knowledge/hard-rules.md,
|
| 27 |
+
knowledge/byoc-data-modes.md).
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import base64
|
| 33 |
+
import json
|
| 34 |
+
from copy import deepcopy
|
| 35 |
+
from typing import Any, Dict, List
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
MUZE_CHART_TYPE = "MUZE_STUDIO"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# --- Archetype A: cell heatmap (dim1 x dim2 -> measure) ----------------------
|
| 42 |
+
# The shape behind "Gross Churn Rate by Region and Plan Family". Replaces the
|
| 43 |
+
# default green HEATMAP, which is the tile boone objected to.
|
| 44 |
+
|
| 45 |
+
_CELL_HEATMAP_JS = """\
|
| 46 |
+
/**
|
| 47 |
+
* Cell heatmap: MEASURE aggregated over X_FIELD x Y_FIELD.
|
| 48 |
+
* Column constants below must match the ANSWER column display names (which
|
| 49 |
+
* carry the aggregation prefix, e.g. "Average Foo"), not the model column
|
| 50 |
+
* names. A name the search does not return is silently absent, not an error.
|
| 51 |
+
*/
|
| 52 |
+
const X_FIELD = '__X_FIELD__';
|
| 53 |
+
const Y_FIELD = '__Y_FIELD__';
|
| 54 |
+
const M_FIELD = '__M_FIELD__';
|
| 55 |
+
|
| 56 |
+
// Resolve the host object. The documented entry point is the BARE identifier;
|
| 57 |
+
// a host that passes viz as a wrapper argument leaves globalThis.viz undefined.
|
| 58 |
+
// typeof alone is not enough: on a binding in its temporal dead zone it throws.
|
| 59 |
+
function getViz() {
|
| 60 |
+
try { if (typeof viz !== 'undefined' && viz) return viz; } catch (e) {}
|
| 61 |
+
try { if (globalThis.viz) return globalThis.viz; } catch (e) {}
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const host = getViz();
|
| 66 |
+
|
| 67 |
+
function done() {
|
| 68 |
+
// Must fire on BOTH the success and failure paths, or Liveboard PDF export
|
| 69 |
+
// blocks on this tile forever.
|
| 70 |
+
try {
|
| 71 |
+
if (host && host.events && host.events.emitRenderCompletedEvent) {
|
| 72 |
+
host.events.emitRenderCompletedEvent();
|
| 73 |
+
}
|
| 74 |
+
} catch (e) {
|
| 75 |
+
console.warn('emitRenderCompletedEvent failed', e);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Build our own mount point if the host has not laid one down yet - the tab
|
| 80 |
+
// order is not contractual, and a null container throws before anything renders.
|
| 81 |
+
function stage() {
|
| 82 |
+
let el = document.getElementById('chart');
|
| 83 |
+
if (!el) {
|
| 84 |
+
el = document.createElement('div');
|
| 85 |
+
el.id = 'chart';
|
| 86 |
+
document.body.appendChild(el);
|
| 87 |
+
}
|
| 88 |
+
return el;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function fail(err) {
|
| 92 |
+
// Never paint into the element that caused the throw.
|
| 93 |
+
const target = document.getElementById('chart') || document.body;
|
| 94 |
+
const pre = document.createElement('pre');
|
| 95 |
+
pre.className = 'chart-error';
|
| 96 |
+
pre.textContent = String((err && err.stack) || err);
|
| 97 |
+
target.appendChild(pre);
|
| 98 |
+
console.error(err);
|
| 99 |
+
done();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Cells sometimes arrive wrapped as { value, formatted }; always unwrap.
|
| 103 |
+
function cellVal(c) {
|
| 104 |
+
if (c && typeof c === 'object' && 'value' in c) return c.value;
|
| 105 |
+
return c;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function loadRows() {
|
| 109 |
+
const gd = host && host.getDataFromSearchQuery;
|
| 110 |
+
if (typeof gd !== 'function') return [];
|
| 111 |
+
const raw = gd().getData();
|
| 112 |
+
return raw.data.map(function (arr) {
|
| 113 |
+
const o = {};
|
| 114 |
+
raw.schema.forEach(function (c, i) { o[c.name] = cellVal(arr[i]); });
|
| 115 |
+
return o;
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
| 120 |
+
|
| 121 |
+
function rampColor(t, stops) {
|
| 122 |
+
const clamped = Math.max(0, Math.min(1, t));
|
| 123 |
+
const span = 1 / (stops.length - 1);
|
| 124 |
+
const i = Math.min(stops.length - 2, Math.floor(clamped / span));
|
| 125 |
+
const local = (clamped - i * span) / span;
|
| 126 |
+
const from = stops[i];
|
| 127 |
+
const to = stops[i + 1];
|
| 128 |
+
return 'rgb(' +
|
| 129 |
+
Math.round(lerp(from[0], to[0], local)) + ',' +
|
| 130 |
+
Math.round(lerp(from[1], to[1], local)) + ',' +
|
| 131 |
+
Math.round(lerp(from[2], to[2], local)) + ')';
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const RAMP = [[224, 242, 254], [8, 145, 178], [12, 74, 110], [217, 119, 6], [220, 38, 38]];
|
| 135 |
+
|
| 136 |
+
function svgEl(name, attrs) {
|
| 137 |
+
const el = document.createElementNS('http://www.w3.org/2000/svg', name);
|
| 138 |
+
Object.keys(attrs || {}).forEach(function (k) { el.setAttribute(k, attrs[k]); });
|
| 139 |
+
return el;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function render() {
|
| 143 |
+
const el = stage();
|
| 144 |
+
el.innerHTML = '';
|
| 145 |
+
|
| 146 |
+
const rows = loadRows();
|
| 147 |
+
if (!rows.length) {
|
| 148 |
+
el.appendChild(document.createTextNode('No data returned for this search.'));
|
| 149 |
+
return;
|
| 150 |
+
}
|
| 151 |
+
for (const field of [X_FIELD, Y_FIELD, M_FIELD]) {
|
| 152 |
+
if (!(field in rows[0])) {
|
| 153 |
+
throw new Error('Search did not return the column "' + field +
|
| 154 |
+
'". Available: ' + Object.keys(rows[0]).join(', '));
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const xLabels = Array.from(new Set(rows.map(function (r) { return r[X_FIELD]; }))).sort();
|
| 159 |
+
const yLabels = Array.from(new Set(rows.map(function (r) { return r[Y_FIELD]; }))).sort();
|
| 160 |
+
const values = rows.map(function (r) { return Number(r[M_FIELD]); })
|
| 161 |
+
.filter(function (v) { return isFinite(v); });
|
| 162 |
+
if (!values.length) throw new Error('Column "' + M_FIELD + '" has no numeric values.');
|
| 163 |
+
const min = Math.min.apply(null, values);
|
| 164 |
+
const max = Math.max.apply(null, values);
|
| 165 |
+
const span = max - min || 1;
|
| 166 |
+
|
| 167 |
+
const margin = { top: 16, right: 16, bottom: 52, left: 132 };
|
| 168 |
+
const totalW = 720;
|
| 169 |
+
const cellH = 56;
|
| 170 |
+
const gap = 6;
|
| 171 |
+
const innerW = totalW - margin.left - margin.right;
|
| 172 |
+
const totalH = yLabels.length * (cellH + gap) - gap;
|
| 173 |
+
const cellW = (innerW - (xLabels.length - 1) * gap) / xLabels.length;
|
| 174 |
+
|
| 175 |
+
const svg = svgEl('svg', {
|
| 176 |
+
viewBox: '0 0 ' + totalW + ' ' + (totalH + margin.top + margin.bottom),
|
| 177 |
+
preserveAspectRatio: 'xMidYMid meet'
|
| 178 |
+
});
|
| 179 |
+
const g = svgEl('g', { transform: 'translate(' + margin.left + ',' + margin.top + ')' });
|
| 180 |
+
svg.appendChild(g);
|
| 181 |
+
|
| 182 |
+
rows.forEach(function (r) {
|
| 183 |
+
const xi = xLabels.indexOf(r[X_FIELD]);
|
| 184 |
+
const yi = yLabels.indexOf(r[Y_FIELD]);
|
| 185 |
+
const v = Number(r[M_FIELD]);
|
| 186 |
+
if (xi < 0 || yi < 0 || !isFinite(v)) return;
|
| 187 |
+
const t = (v - min) / span;
|
| 188 |
+
const cx = xi * (cellW + gap);
|
| 189 |
+
const cy = yi * (cellH + gap);
|
| 190 |
+
g.appendChild(svgEl('rect', {
|
| 191 |
+
x: cx, y: cy, width: cellW, height: cellH, rx: 8, ry: 8, fill: rampColor(t, RAMP)
|
| 192 |
+
}));
|
| 193 |
+
const label = svgEl('text', {
|
| 194 |
+
x: cx + cellW / 2, y: cy + cellH / 2 + 5, 'text-anchor': 'middle',
|
| 195 |
+
'font-size': '15', 'font-weight': '600', fill: t < 0.15 ? '#0C4A6E' : '#ffffff'
|
| 196 |
+
});
|
| 197 |
+
label.textContent = Math.abs(v) >= 1000 ? Math.round(v).toLocaleString() : v.toFixed(2);
|
| 198 |
+
g.appendChild(label);
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
yLabels.forEach(function (name, i) {
|
| 202 |
+
const t = svgEl('text', {
|
| 203 |
+
x: -12, y: i * (cellH + gap) + cellH / 2 + 5, 'text-anchor': 'end',
|
| 204 |
+
'font-size': '13', fill: '#64748B'
|
| 205 |
+
});
|
| 206 |
+
t.textContent = name;
|
| 207 |
+
g.appendChild(t);
|
| 208 |
+
});
|
| 209 |
+
xLabels.forEach(function (name, i) {
|
| 210 |
+
const t = svgEl('text', {
|
| 211 |
+
x: i * (cellW + gap) + cellW / 2, y: totalH + 26, 'text-anchor': 'middle',
|
| 212 |
+
'font-size': '13', fill: '#64748B'
|
| 213 |
+
});
|
| 214 |
+
t.textContent = name;
|
| 215 |
+
g.appendChild(t);
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
el.appendChild(svg);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
try {
|
| 222 |
+
render();
|
| 223 |
+
done();
|
| 224 |
+
} catch (err) {
|
| 225 |
+
fail(err);
|
| 226 |
+
}
|
| 227 |
+
"""
|
| 228 |
+
|
| 229 |
+
# html, body height is load-bearing: a percentage height resolves only against
|
| 230 |
+
# an ancestor chain that is definite the whole way up, and the tile's <body> has
|
| 231 |
+
# no explicit height. Without this the chart renders into a 0px-tall stage and
|
| 232 |
+
# the tile is blank with a clean console.
|
| 233 |
+
_CELL_HEATMAP_CSS = """\
|
| 234 |
+
html, body { height: 100%; margin: 0; padding: 0; }
|
| 235 |
+
body { font-family: 'DM Sans', system-ui, -apple-system, sans-serif; background: #fff; }
|
| 236 |
+
#chart { height: 100%; width: 100%; }
|
| 237 |
+
#chart svg { width: 100%; height: 100%; display: block; }
|
| 238 |
+
.chart-error { color: #b91c1c; font-size: 12px; white-space: pre-wrap; padding: 8px; }
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
_CELL_HEATMAP_HTML = """<div id="chart"></div>"""
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
CELL_HEATMAP = "cell_heatmap"
|
| 245 |
+
|
| 246 |
+
_ARCHETYPES: Dict[str, Dict[str, Any]] = {
|
| 247 |
+
CELL_HEATMAP: {
|
| 248 |
+
"js": _CELL_HEATMAP_JS,
|
| 249 |
+
"css": _CELL_HEATMAP_CSS,
|
| 250 |
+
"html": _CELL_HEATMAP_HTML,
|
| 251 |
+
"fields": ("x", "y", "measure"),
|
| 252 |
+
},
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def available_archetypes() -> List[str]:
|
| 257 |
+
return sorted(_ARCHETYPES)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _b64(text: str) -> str:
|
| 261 |
+
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def build_custom_visual_props(archetype: str, bindings: Dict[str, str]) -> str:
|
| 265 |
+
"""Return the ``custom_visual_props`` string for a MUZE_STUDIO chart.
|
| 266 |
+
|
| 267 |
+
``bindings`` maps the archetype's field slots to ANSWER column display
|
| 268 |
+
names — the aggregated names ("Average Foo"), not model column names.
|
| 269 |
+
"""
|
| 270 |
+
spec = _ARCHETYPES.get(archetype)
|
| 271 |
+
if spec is None:
|
| 272 |
+
raise ValueError(
|
| 273 |
+
f"unknown Muze archetype {archetype!r}; have {available_archetypes()}"
|
| 274 |
+
)
|
| 275 |
+
missing = [f for f in spec["fields"] if not bindings.get(f)]
|
| 276 |
+
if missing:
|
| 277 |
+
raise ValueError(f"{archetype} requires bindings for {missing}")
|
| 278 |
+
|
| 279 |
+
js = spec["js"]
|
| 280 |
+
for slot, placeholder in (
|
| 281 |
+
("x", "__X_FIELD__"), ("y", "__Y_FIELD__"), ("measure", "__M_FIELD__"),
|
| 282 |
+
):
|
| 283 |
+
if slot in spec["fields"]:
|
| 284 |
+
value = bindings[slot]
|
| 285 |
+
if "'" in value or "\\" in value:
|
| 286 |
+
raise ValueError(f"column name cannot contain a quote or backslash: {value!r}")
|
| 287 |
+
js = js.replace(placeholder, value)
|
| 288 |
+
|
| 289 |
+
if "</script>" in js or "</script>" in spec["html"]:
|
| 290 |
+
raise ValueError("a literal </script> breaks the host's HTML parser")
|
| 291 |
+
|
| 292 |
+
client_state = {
|
| 293 |
+
"version": 1,
|
| 294 |
+
"playground": {
|
| 295 |
+
"version": "v1",
|
| 296 |
+
"code": {
|
| 297 |
+
"jsCodeBase64": _b64(js),
|
| 298 |
+
"cssCodeBase64": _b64(spec["css"]),
|
| 299 |
+
"htmlCodeBase64": _b64(spec["html"]),
|
| 300 |
+
},
|
| 301 |
+
},
|
| 302 |
+
}
|
| 303 |
+
return json.dumps({"clientState": json.dumps(client_state)})
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def build_muze_viz(
|
| 307 |
+
viz_id: str,
|
| 308 |
+
name: str,
|
| 309 |
+
archetype: str,
|
| 310 |
+
bindings: Dict[str, str],
|
| 311 |
+
search_query: str,
|
| 312 |
+
sibling_answer: Dict[str, Any],
|
| 313 |
+
) -> Dict[str, Any]:
|
| 314 |
+
"""Build a MUZE_STUDIO visualization bound to the given columns.
|
| 315 |
+
|
| 316 |
+
The caller owns the search query, and it MUST select every bound column —
|
| 317 |
+
a column the chart names and the search omits is silently absent.
|
| 318 |
+
"""
|
| 319 |
+
columns = [bindings[f] for f in _ARCHETYPES[archetype]["fields"]]
|
| 320 |
+
return {
|
| 321 |
+
"id": viz_id,
|
| 322 |
+
"answer": {
|
| 323 |
+
"name": name,
|
| 324 |
+
"tables": deepcopy(sibling_answer.get("tables", [])),
|
| 325 |
+
"search_query": search_query,
|
| 326 |
+
"answer_columns": [{"name": c} for c in columns],
|
| 327 |
+
"chart": {
|
| 328 |
+
"type": MUZE_CHART_TYPE,
|
| 329 |
+
"chart_columns": [{"column_id": c} for c in columns],
|
| 330 |
+
"client_state": "",
|
| 331 |
+
"custom_visual_props": build_custom_visual_props(archetype, bindings),
|
| 332 |
+
},
|
| 333 |
+
"display_mode": "CHART_MODE",
|
| 334 |
+
},
|
| 335 |
+
}
|
demoprep_app/liveboards/tml_yaml.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safe YAML loading for ThoughtSpot TML.
|
| 2 |
+
|
| 3 |
+
TML is YAML, but ThoughtSpot emits constructs that `yaml.safe_load` refuses:
|
| 4 |
+
|
| 5 |
+
* Date filters contain a bare ``oper: =``. In YAML 1.1 a lone ``=`` is the
|
| 6 |
+
reserved *value* tag (``tag:yaml.org,2002:value``), and SafeLoader has no
|
| 7 |
+
constructor for it, so the whole document raises ConstructorError. Any
|
| 8 |
+
liveboard carrying a ``LAST_N_PERIOD`` filter — which both golden reference
|
| 9 |
+
boards do — is unparseable without this.
|
| 10 |
+
|
| 11 |
+
Use :func:`load_tml` anywhere TML is read back from ThoughtSpot.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any, Dict
|
| 17 |
+
|
| 18 |
+
import yaml
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TMLLoader(yaml.SafeLoader):
|
| 22 |
+
"""SafeLoader that tolerates ThoughtSpot's TML quirks."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _construct_value(loader: yaml.Loader, node: yaml.Node) -> str:
|
| 26 |
+
"""Return the reserved YAML ``=`` tag as the plain string it is in TML."""
|
| 27 |
+
return loader.construct_scalar(node)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
TMLLoader.add_constructor("tag:yaml.org,2002:value", _construct_value)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_tml(text: str | bytes) -> Dict[str, Any]:
|
| 34 |
+
"""Parse a TML document. Returns {} for empty input.
|
| 35 |
+
|
| 36 |
+
Raises yaml.YAMLError for genuinely malformed TML — callers should not
|
| 37 |
+
swallow that, since it means ThoughtSpot returned something unexpected.
|
| 38 |
+
"""
|
| 39 |
+
if not text:
|
| 40 |
+
return {}
|
| 41 |
+
return yaml.load(text, Loader=TMLLoader) or {}
|
demoprep_app/pipeline/build_demo.py
CHANGED
|
@@ -30,6 +30,7 @@ from dataclasses import dataclass, field
|
|
| 30 |
|
| 31 |
from demoprep_app.dataset.contracts import DatasetBundle
|
| 32 |
from demoprep_app.dataset.engine import BlueprintEngine
|
|
|
|
| 33 |
from demoprep_app.dataset.validator import ValidationReport, validate_bundle
|
| 34 |
from demoprep_app.ddl import DatasetDdlCompiler
|
| 35 |
from demoprep_app.scenario.blueprint import DemoBlueprint
|
|
@@ -41,6 +42,10 @@ from demoprep_app.scenario.contract import ScenarioContract
|
|
| 41 |
MAX_TUNING_ROUNDS = 3
|
| 42 |
# When an insight is too weak, multiply its distance-from-1.0 by this.
|
| 43 |
BOOST_FACTOR = 1.6
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
@dataclass(slots=True)
|
|
@@ -133,14 +138,35 @@ def build_demo(
|
|
| 133 |
dataset: DatasetBundle | None = None
|
| 134 |
for round_num in range(1, MAX_TUNING_ROUNDS + 1):
|
| 135 |
progress(f"Generating dataset (round {round_num})...")
|
| 136 |
-
dataset =
|
|
|
|
|
|
|
| 137 |
report = validate_bundle(blueprint, dataset)
|
| 138 |
progress(report.summary())
|
| 139 |
if report.passed:
|
| 140 |
break
|
| 141 |
-
if report.integrity_errors
|
| 142 |
-
#
|
|
|
|
| 143 |
raise RuntimeError("Generated dataset failed integrity checks:\n" + report.summary())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
_boost_weak_insights(blueprint, report)
|
| 145 |
|
| 146 |
if report is not None and not report.passed:
|
|
@@ -165,6 +191,43 @@ def build_demo(
|
|
| 165 |
)
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) -> None:
|
| 169 |
"""Contract-level cleanup: strengthen only what failed, deterministically."""
|
| 170 |
failed = {c.insight_id for c in report.failed_insights()}
|
|
|
|
| 30 |
|
| 31 |
from demoprep_app.dataset.contracts import DatasetBundle
|
| 32 |
from demoprep_app.dataset.engine import BlueprintEngine
|
| 33 |
+
from demoprep_app.dataset.formula import FormulaEvaluationError
|
| 34 |
from demoprep_app.dataset.validator import ValidationReport, validate_bundle
|
| 35 |
from demoprep_app.ddl import DatasetDdlCompiler
|
| 36 |
from demoprep_app.scenario.blueprint import DemoBlueprint
|
|
|
|
| 42 |
MAX_TUNING_ROUNDS = 3
|
| 43 |
# When an insight is too weak, multiply its distance-from-1.0 by this.
|
| 44 |
BOOST_FACTOR = 1.6
|
| 45 |
+
# How many times we repair a formula the engine refused to compute and try
|
| 46 |
+
# again. Formula validation at blueprint time should make this dead code; it
|
| 47 |
+
# exists so an unforeseen formula failure costs one measure, not the demo.
|
| 48 |
+
MAX_FORMULA_REPAIR_ATTEMPTS = 3
|
| 49 |
|
| 50 |
|
| 51 |
@dataclass(slots=True)
|
|
|
|
| 138 |
dataset: DatasetBundle | None = None
|
| 139 |
for round_num in range(1, MAX_TUNING_ROUNDS + 1):
|
| 140 |
progress(f"Generating dataset (round {round_num})...")
|
| 141 |
+
dataset = _generate_with_formula_repair(
|
| 142 |
+
engine, blueprint, row_count_guidance, warnings, progress
|
| 143 |
+
)
|
| 144 |
report = validate_bundle(blueprint, dataset)
|
| 145 |
progress(report.summary())
|
| 146 |
if report.passed:
|
| 147 |
break
|
| 148 |
+
if report.integrity_errors:
|
| 149 |
+
# Dangling keys / column mismatches are engine bugs and the
|
| 150 |
+
# Snowflake load would fail on them anyway — fail here, clearly.
|
| 151 |
raise RuntimeError("Generated dataset failed integrity checks:\n" + report.summary())
|
| 152 |
+
if report.formula_errors:
|
| 153 |
+
# A stored derived value disagrees with its formula. Repair the
|
| 154 |
+
# formula and regenerate; if there is nothing left to repair, SHIP
|
| 155 |
+
# with a loud warning instead of aborting — the post-load quality
|
| 156 |
+
# gate recomputes or omits the column, and a board with one suspect
|
| 157 |
+
# tile beats no board at all (BLOCKING_ISSUES_2026_08_26, ISSUE C).
|
| 158 |
+
repaired = blueprint.repair_formulas()
|
| 159 |
+
if repaired:
|
| 160 |
+
for fix in repaired:
|
| 161 |
+
progress(f"formula repair: {fix}")
|
| 162 |
+
warnings.extend(f"formula repair: {fix}" for fix in repaired)
|
| 163 |
+
continue
|
| 164 |
+
warnings.append(
|
| 165 |
+
"Stored derived values deviate from their formulas; shipping anyway so the "
|
| 166 |
+
"demo exists. The Snowflake quality gate will recompute or omit them:\n"
|
| 167 |
+
+ report.summary()
|
| 168 |
+
)
|
| 169 |
+
break
|
| 170 |
_boost_weak_insights(blueprint, report)
|
| 171 |
|
| 172 |
if report is not None and not report.passed:
|
|
|
|
| 191 |
)
|
| 192 |
|
| 193 |
|
| 194 |
+
def _generate_with_formula_repair(
|
| 195 |
+
engine: BlueprintEngine,
|
| 196 |
+
blueprint: DemoBlueprint,
|
| 197 |
+
row_count_guidance: int | None,
|
| 198 |
+
warnings: list[str],
|
| 199 |
+
progress,
|
| 200 |
+
) -> DatasetBundle:
|
| 201 |
+
"""Generate rows, repairing (never swallowing) a formula the engine rejects.
|
| 202 |
+
|
| 203 |
+
The engine now RAISES on an uncomputable formula instead of writing 0.0 to
|
| 204 |
+
every row. That correctness fix must not become a new way to produce
|
| 205 |
+
nothing, so each failure costs the offending measure and the run continues.
|
| 206 |
+
"""
|
| 207 |
+
last_error: FormulaEvaluationError | None = None
|
| 208 |
+
for _ in range(MAX_FORMULA_REPAIR_ATTEMPTS):
|
| 209 |
+
try:
|
| 210 |
+
dataset = engine.generate(blueprint, row_count_guidance)
|
| 211 |
+
except FormulaEvaluationError as exc:
|
| 212 |
+
last_error = exc
|
| 213 |
+
progress(f"Formula rejected during generation: {exc}")
|
| 214 |
+
fixes = blueprint.repair_formulas()
|
| 215 |
+
if not fixes and exc.measure:
|
| 216 |
+
fixes = blueprint.drop_measure(exc.measure, reason=exc.reason)
|
| 217 |
+
if not fixes:
|
| 218 |
+
raise
|
| 219 |
+
for fix in fixes:
|
| 220 |
+
progress(f"formula repair: {fix}")
|
| 221 |
+
warnings.extend(f"formula repair: {fix}" for fix in fixes)
|
| 222 |
+
continue
|
| 223 |
+
warnings.extend(engine.formula_warnings())
|
| 224 |
+
return dataset
|
| 225 |
+
raise RuntimeError(
|
| 226 |
+
"Dataset generation kept failing on measure formulas after "
|
| 227 |
+
f"{MAX_FORMULA_REPAIR_ATTEMPTS} repair attempts. Last error: {last_error}"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) -> None:
|
| 232 |
"""Contract-level cleanup: strengthen only what failed, deterministically."""
|
| 233 |
failed = {c.insight_id for c in report.failed_insights()}
|
demoprep_app/portal/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Portal builder assets (`build_portal` groundwork)
|
| 2 |
+
|
| 3 |
+
Everything needed to turn a finished DemoPrep run into a branded static portal
|
| 4 |
+
site. The `build_portal(run_id)` MCP tool is **not implemented yet** — this
|
| 5 |
+
directory holds the verified building blocks it will use.
|
| 6 |
+
|
| 7 |
+
Design doc: `docs/agentspot_portal_handoff_2026_08_24.md`
|
| 8 |
+
Verification results: `dev_notes/portal_template_spike.md`
|
| 9 |
+
|
| 10 |
+
## Provenance — do not edit `template-tse/` by hand
|
| 11 |
+
|
| 12 |
+
`template-tse/`, `scripts/apply-spec.mjs`, and `references/` are **vendored** from
|
| 13 |
+
the `rebrand-thoughtspot-portal` skill in ThoughtSpot's public `tse_demos` repo:
|
| 14 |
+
|
| 15 |
+
repo: https://github.com/thoughtspot/tse_demos
|
| 16 |
+
path: .claude/skills/rebrand-thoughtspot-portal/
|
| 17 |
+
commit: 0c68520f51b15dcd12088e638273b914af2c7077 (2026-08-25)
|
| 18 |
+
|
| 19 |
+
The template is Mani's team's and evolves independently. Upgrade deliberately by
|
| 20 |
+
re-vendoring at a new pinned commit and re-testing — never by patching in place.
|
| 21 |
+
|
| 22 |
+
`node_modules/` and `dist/` are intentionally excluded (121 MB of deps; the
|
| 23 |
+
codemod runs `npm install` itself on first use).
|
| 24 |
+
|
| 25 |
+
## How it works
|
| 26 |
+
|
| 27 |
+
interview -> spec.json -> deterministic codemod -> npm run build -> static host
|
| 28 |
+
|
| 29 |
+
DemoPrep skips the interview: it already knows the host, liveboard GUID, model
|
| 30 |
+
GUID, columns, company and palette because it created the run, so it authors
|
| 31 |
+
`spec.json` directly. Spec contract: `references/spec-schema.md`.
|
| 32 |
+
|
| 33 |
+
## Build a portal from a spec (verified working)
|
| 34 |
+
|
| 35 |
+
node demoprep_app/portal/scripts/apply-spec.mjs demoprep_app/portal/specs/triumph.json
|
| 36 |
+
(cd <slug>-tse && npm run build) # output in dist/
|
| 37 |
+
|
| 38 |
+
`specs/triumph.json` and `specs/vantage.json` are real worked examples (live
|
| 39 |
+
GUIDs on sebe). Asset paths in a spec resolve relative to the current directory —
|
| 40 |
+
see `assets/` for the placeholder logos those two specs reference.
|
| 41 |
+
|
| 42 |
+
## Known gotchas (hit during verification — keep these fixed in any generator)
|
| 43 |
+
|
| 44 |
+
1. `client` must be a single alphanumeric token (it becomes component/file names).
|
| 45 |
+
`"Vantage Capital"` produces `AskVantage Capital.tsx` and ~40 tsc errors. Pass
|
| 46 |
+
CamelCase (`VantageCapital`); the display name belongs in `content.company`.
|
| 47 |
+
2. The codemod's tab pruning leaves dangling imports: with `inline:false` /
|
| 48 |
+
`action:false` it deletes `Carriers.tsx`/`Capacity.tsx` but not their `import`
|
| 49 |
+
lines in `src/App.tsx`, nor the now-unused `Truck`/`Activity` icons in
|
| 50 |
+
`src/components/TopBar.tsx`. `tsc` fails on both. Strip them post-codemod.
|
| 51 |
+
3. `spec.action` and every `spec.columns.*` field are read unconditionally, even
|
| 52 |
+
when those features are off — always populate them.
|
| 53 |
+
4. Hosting a portal needs the **exact** hostname in the cluster's CORS whitelist;
|
| 54 |
+
subdomain wildcards (`*.static.hf.space`) are rejected. `build_portal` must
|
| 55 |
+
return that instruction — the config API is 403 for non-admins.
|
demoprep_app/portal/assets/triumph-logo.svg
ADDED
|
|
demoprep_app/portal/assets/vantage-logo.svg
ADDED
|
|
demoprep_app/portal/builder.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a branded ThoughtSpot portal from a deployed liveboard.
|
| 2 |
+
|
| 3 |
+
The importable core behind the `build_portal` MCP tool. No CLI, no argparse:
|
| 4 |
+
callers pass values and get a dict back, or a PortalBuildError with a specific
|
| 5 |
+
message. `scripts/build_portal.py` is a thin CLI over this for local testing.
|
| 6 |
+
|
| 7 |
+
Chain: liveboard GUID -> model TML -> spec.json -> codemod -> npm run build
|
| 8 |
+
-> static HF Space -> URL (+ the CORS entry the cluster requires)
|
| 9 |
+
|
| 10 |
+
Two Hugging Face Space constraints shape this module:
|
| 11 |
+
|
| 12 |
+
1. **Everything is written to a writable temp dir, never the repo.** HF Spaces
|
| 13 |
+
run the container as a non-root user inside a root-owned WORKDIR, so writing
|
| 14 |
+
build output next to the code raises PermissionError at runtime — the exact
|
| 15 |
+
shape of the 2026-08-25 MCP outage.
|
| 16 |
+
2. **The template's node_modules must already exist in the image.** The codemod
|
| 17 |
+
installs them itself when missing, which would be a write into that same
|
| 18 |
+
read-only tree. The Dockerfile pre-installs them as root at build time.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import re
|
| 25 |
+
import shutil
|
| 26 |
+
import subprocess
|
| 27 |
+
import sys
|
| 28 |
+
import tempfile
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
REPO = Path(__file__).resolve().parent.parent.parent
|
| 32 |
+
PORTAL = REPO / "demoprep_app" / "portal"
|
| 33 |
+
CODEMOD = PORTAL / "scripts" / "apply-spec.mjs"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PortalBuildError(Exception):
|
| 37 |
+
"""Any failure that should be reported to the caller verbatim."""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# TS environment label -> (base url, .env key var). Mirrors the app's env table.
|
| 41 |
+
TS_ENVS = {
|
| 42 |
+
"secloud": ("https://se-thoughtspot-cloud.thoughtspot.cloud", "TS_ENV_1_KEY_VAR"),
|
| 43 |
+
"sebe": ("https://sebe.thoughtspotstaging.cloud", "TS_ENV_2_KEY_VAR"),
|
| 44 |
+
"sebe-demo": ("https://sebe.thoughtspotstaging.cloud", "TS_ENV_3_KEY_VAR"),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Measures whose name matches these read as money in the portal UI.
|
| 48 |
+
CURRENCY_HINTS = ("usd", "revenue", "amount", "cost", "pay", "price", "margin usd",
|
| 49 |
+
"balance", "savings", "spend")
|
| 50 |
+
# Attributes matching these make good grouping/segment filters.
|
| 51 |
+
SEGMENT_HINTS = ("tier", "segment", "category", "type", "grade", "status", "group", "class")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------- ThoughtSpot
|
| 55 |
+
|
| 56 |
+
def ts_client(env: str, as_user: str = ""):
|
| 57 |
+
"""Authenticated deployer. Fails loudly if the env key is missing.
|
| 58 |
+
|
| 59 |
+
`as_user` selects the ThoughtSpot identity to act as via trusted auth. This
|
| 60 |
+
matters because TML export is permission-scoped: a model built by another SE
|
| 61 |
+
is FORBIDDEN to everyone else, so a portal over their liveboard has to be
|
| 62 |
+
built as them.
|
| 63 |
+
"""
|
| 64 |
+
from thoughtspot_deployer import ThoughtSpotDeployer
|
| 65 |
+
|
| 66 |
+
if env not in TS_ENVS:
|
| 67 |
+
raise PortalBuildError(f"unknown ts_env '{env}'. choose from: {', '.join(TS_ENVS)}")
|
| 68 |
+
base, key_var = TS_ENVS[env]
|
| 69 |
+
secret = os.getenv(key_var)
|
| 70 |
+
if not secret:
|
| 71 |
+
raise PortalBuildError(f"{key_var} is not set in .env — cannot authenticate to {base}")
|
| 72 |
+
user = as_user or os.getenv("TEST_USER") or os.getenv("USER_EMAIL")
|
| 73 |
+
if not user:
|
| 74 |
+
raise PortalBuildError("no authenticated user available (set TEST_USER in .env)")
|
| 75 |
+
d = ThoughtSpotDeployer(base_url=base, username=user, secret_key=secret)
|
| 76 |
+
d.authenticate_trusted()
|
| 77 |
+
return d, base
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def export_tml(client, base: str, guid: str, associated: bool = False) -> list[dict]:
|
| 81 |
+
r = client.session.post(
|
| 82 |
+
f"{base}/api/rest/2.0/metadata/tml/export",
|
| 83 |
+
json={"metadata": [{"identifier": guid}], "export_associated": associated,
|
| 84 |
+
"export_fqn": True},
|
| 85 |
+
)
|
| 86 |
+
if not r.ok:
|
| 87 |
+
raise PortalBuildError(f"TML export failed for {guid}: HTTP {r.status} {r.text[:200]}"
|
| 88 |
+
if hasattr(r, "status") else
|
| 89 |
+
f"TML export failed for {guid}: HTTP {r.status_code} {r.text[:200]}")
|
| 90 |
+
return r.json()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def resolve_model(client, base: str, liveboard_guid: str) -> str:
|
| 94 |
+
"""Pull the model GUID out of a liveboard's first visualization."""
|
| 95 |
+
import yaml
|
| 96 |
+
|
| 97 |
+
docs = export_tml(client, base, liveboard_guid)
|
| 98 |
+
info = docs[0].get("info", {})
|
| 99 |
+
if info.get("type") != "liveboard":
|
| 100 |
+
raise PortalBuildError(f"{liveboard_guid} is a '{info.get('type')}', not a liveboard — "
|
| 101 |
+
"a viz GUID in a liveboard slot is the #1 cause of a blank embed")
|
| 102 |
+
lb = yaml.safe_load(docs[0]["edoc"])["liveboard"]
|
| 103 |
+
for viz in lb.get("visualizations", []):
|
| 104 |
+
for tbl in (viz.get("answer", {}) or {}).get("tables", []) or []:
|
| 105 |
+
if tbl.get("fqn"):
|
| 106 |
+
return tbl["fqn"]
|
| 107 |
+
raise PortalBuildError("could not find a model GUID in that liveboard's visualizations")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def model_columns(client, base: str, model_guid: str):
|
| 111 |
+
"""Return (attributes, measures, date_columns, col_table).
|
| 112 |
+
|
| 113 |
+
`col_table` maps display name -> source table, which is what lets the date
|
| 114 |
+
column be matched to the fact its metrics actually live on.
|
| 115 |
+
|
| 116 |
+
Date columns need the associated TABLE TMLs: a model TML export strips the
|
| 117 |
+
`calendar` property, so dates are only identifiable via the underlying
|
| 118 |
+
column data types.
|
| 119 |
+
"""
|
| 120 |
+
import yaml
|
| 121 |
+
|
| 122 |
+
docs = export_tml(client, base, model_guid, associated=True)
|
| 123 |
+
readable = [d for d in docs if d.get("edoc")]
|
| 124 |
+
if not readable:
|
| 125 |
+
detail = ""
|
| 126 |
+
for d in docs:
|
| 127 |
+
msg = ((d.get("info") or {}).get("status") or {}).get("error_message", "")
|
| 128 |
+
if msg:
|
| 129 |
+
detail = msg.splitlines()[0]
|
| 130 |
+
break
|
| 131 |
+
owner_hint = (
|
| 132 |
+
" The model is owned by another user and TML export is permission-scoped: "
|
| 133 |
+
"build as its owner (as_user=...) or have them share it."
|
| 134 |
+
if "FORBIDDEN" in detail.upper() else ""
|
| 135 |
+
)
|
| 136 |
+
raise PortalBuildError(
|
| 137 |
+
f"cannot read model {model_guid}: {detail or 'export returned no TML'}.{owner_hint}"
|
| 138 |
+
)
|
| 139 |
+
model_doc = next((d for d in readable if "model" in yaml.safe_load(d["edoc"])), None)
|
| 140 |
+
if not model_doc:
|
| 141 |
+
raise PortalBuildError(f"{model_guid} did not export as a model")
|
| 142 |
+
model = yaml.safe_load(model_doc["edoc"])["model"]
|
| 143 |
+
|
| 144 |
+
attributes, measures, by_id, col_table = [], [], {}, {}
|
| 145 |
+
for col in model.get("columns", []) or []:
|
| 146 |
+
props = col.get("properties", {}) or {}
|
| 147 |
+
if props.get("is_hidden"):
|
| 148 |
+
continue
|
| 149 |
+
name = col["name"]
|
| 150 |
+
cid = col.get("column_id", "")
|
| 151 |
+
by_id[cid] = name
|
| 152 |
+
col_table[name] = cid.split("::")[0] if "::" in cid else ""
|
| 153 |
+
(measures if props.get("column_type") == "MEASURE" else attributes).append(name)
|
| 154 |
+
|
| 155 |
+
# physical data types -> which model columns are dates
|
| 156 |
+
date_cols: list[str] = []
|
| 157 |
+
for d in readable:
|
| 158 |
+
doc = yaml.safe_load(d["edoc"])
|
| 159 |
+
tbl = doc.get("table")
|
| 160 |
+
if not tbl:
|
| 161 |
+
continue
|
| 162 |
+
tname = tbl.get("name", "")
|
| 163 |
+
for col in tbl.get("columns", []) or []:
|
| 164 |
+
dtype = str((col.get("db_column_properties") or {}).get("data_type", "")).upper()
|
| 165 |
+
if dtype in ("DATE", "DATETIME", "TIMESTAMP"):
|
| 166 |
+
display = by_id.get(f"{tname}::{col.get('db_column_name', col.get('name'))}")
|
| 167 |
+
if display and display not in date_cols:
|
| 168 |
+
date_cols.append(display)
|
| 169 |
+
# fall back to name-shaped detection only if types gave us nothing
|
| 170 |
+
if not date_cols:
|
| 171 |
+
date_cols = [a for a in attributes
|
| 172 |
+
if re.search(r"\b(date|month|day|week|quarter|year)\b", a, re.I)]
|
| 173 |
+
return attributes, measures, date_cols, col_table
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---------------------------------------------------------------- spec build
|
| 177 |
+
|
| 178 |
+
def camel_token(name: str) -> str:
|
| 179 |
+
"""`client` becomes component and FILE names, so it must be one bare token.
|
| 180 |
+
|
| 181 |
+
"Vantage Capital" -> "VantageCapital". A space here produces
|
| 182 |
+
`AskVantage Capital.tsx` and ~40 tsc errors.
|
| 183 |
+
"""
|
| 184 |
+
tok = re.sub(r"[^A-Za-z0-9]+", " ", name).title().replace(" ", "")
|
| 185 |
+
if not tok:
|
| 186 |
+
raise PortalBuildError(f"cannot derive a component token from company name {name!r}")
|
| 187 |
+
return tok
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def pick_filters(attributes: list[str]) -> tuple[list[str], list[str]]:
|
| 191 |
+
"""Two candidate lists for the portal's filter chips: first that returns data wins."""
|
| 192 |
+
names = [a for a in attributes if a.lower().endswith("name")]
|
| 193 |
+
segments = [a for a in attributes if any(h in a.lower() for h in SEGMENT_HINTS)]
|
| 194 |
+
primary = (names[:1] + segments[:1]) or attributes[:2]
|
| 195 |
+
remaining = [a for a in names[1:] + segments if a not in primary]
|
| 196 |
+
secondary = remaining[:2] or [a for a in attributes if a not in primary][:2]
|
| 197 |
+
if not primary or not secondary:
|
| 198 |
+
raise PortalBuildError("model has too few attribute columns to build filters from")
|
| 199 |
+
return primary, secondary
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def metric_spec(measures: list[str]) -> list[dict]:
|
| 203 |
+
out = []
|
| 204 |
+
for m in measures[:2]:
|
| 205 |
+
fmt = "currency" if any(h in m.lower() for h in CURRENCY_HINTS) else "number"
|
| 206 |
+
label = re.sub(r"\s+Usd$", "", m, flags=re.I)
|
| 207 |
+
label = re.sub(r"\s+Pct$", " %", label, flags=re.I)
|
| 208 |
+
out.append({"label": label, "format": fmt, "candidates": [m]})
|
| 209 |
+
return out
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def theme(primary: str, accent: str) -> dict:
|
| 213 |
+
"""Full :root token dictionaries — the codemod replaces the whole block, so
|
| 214 |
+
every token the template reads must be present in both light and dark."""
|
| 215 |
+
def rgb(hex_):
|
| 216 |
+
h = hex_.lstrip("#")
|
| 217 |
+
return ", ".join(str(int(h[i:i + 2], 16)) for i in (0, 2, 4))
|
| 218 |
+
|
| 219 |
+
font = "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif"
|
| 220 |
+
light = {
|
| 221 |
+
"--sl-cream": "#f7f8fa", "--sl-cream-2": "#eceef2", "--sl-white": "#ffffff",
|
| 222 |
+
"--sl-evergreen": primary, "--sl-evergreen-2": primary,
|
| 223 |
+
"--sl-green": primary, "--sl-green-dark": accent,
|
| 224 |
+
"--sl-primary-rgb": rgb(primary),
|
| 225 |
+
"--intime-blue": accent, "--sl-gold": "#e8b53c", "--sl-gold-ink": "#5f4708",
|
| 226 |
+
"--intime-grad": f"linear-gradient(90deg, {primary} 0%, {accent} 100%)",
|
| 227 |
+
"--sl-mint": "#e8f1fb", "--sl-mint-2": "#d5e6f7", "--sl-coral": "#a2191f",
|
| 228 |
+
"--sl-warn": "#fffbe6", "--sl-warn-ink": "#5f4708",
|
| 229 |
+
"--sl-ink": "#111827", "--sl-muted": "#5a6a7e",
|
| 230 |
+
"--sl-border": "#e2e6ec", "--sl-border-soft": "#eef1f5",
|
| 231 |
+
"--font-serif": font, "--font-sans": font, "--topbar-h": "66px",
|
| 232 |
+
"--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)",
|
| 233 |
+
"--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)",
|
| 234 |
+
"--brand-navy": primary, "--brand-accent": accent,
|
| 235 |
+
}
|
| 236 |
+
dark = dict(light)
|
| 237 |
+
dark.update({
|
| 238 |
+
"--sl-cream": "#0b1622", "--sl-cream-2": "#101f2f", "--sl-white": "#14293d",
|
| 239 |
+
"--sl-evergreen": "#eef4fa", "--sl-evergreen-2": "#dbe6f0",
|
| 240 |
+
"--sl-green": accent, "--sl-green-dark": accent,
|
| 241 |
+
"--sl-primary-rgb": rgb(accent),
|
| 242 |
+
"--sl-mint": "#102a40", "--sl-mint-2": "#16354e", "--sl-coral": "#f0616a",
|
| 243 |
+
"--sl-warn": "#2a2413", "--sl-warn-ink": "#e8b53c",
|
| 244 |
+
"--sl-ink": "#eaf1f7", "--sl-muted": "#9fb2c6",
|
| 245 |
+
"--sl-border": "#23415a", "--sl-border-soft": "#172f44",
|
| 246 |
+
"--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)",
|
| 247 |
+
"--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)",
|
| 248 |
+
})
|
| 249 |
+
return {"default": "light", "light": light, "dark": dark}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def swaps(primary: str, accent: str) -> dict:
|
| 253 |
+
"""Fixed left-hand template constants; only the right-hand side is brand."""
|
| 254 |
+
return {
|
| 255 |
+
"embedSwaps": [["#4F5BD5", accent], ["#3F49B8", accent], ["#333C99", primary],
|
| 256 |
+
["#22B8CF", accent], ["#0b0d10", "#0b1622"], ["#14181f", "#14293d"],
|
| 257 |
+
["#1a212b", "#16354e"], ["#232a34", "#23415a"], ["#0b1f19", "#101f2f"],
|
| 258 |
+
["rgba(45,164,191,0.14)", "rgba(122,180,232,0.16)"]],
|
| 259 |
+
"greenSwaps": [["#0f9a63", primary], ["#0c8554", primary], ["#d9ecea", "#d5e6f7"],
|
| 260 |
+
["#cfe3e2", "#c2ddf4"], ["#1bb978", accent], ["#15ae6e", primary]],
|
| 261 |
+
"cssSwaps": [["#0e1116", "#101f2f"], ["#10222a", "#101f2f"], ["#123047", "#14293d"]],
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def content(company: str, use_case: str, ai_name: str, website: str,
|
| 266 |
+
primary_label: str, secondary_label: str, samples: list[str]) -> dict:
|
| 267 |
+
return {
|
| 268 |
+
"company": company,
|
| 269 |
+
"aiName": ai_name,
|
| 270 |
+
"website": website,
|
| 271 |
+
"loginTitleHtml": f"{use_case},<br />in one place.",
|
| 272 |
+
"loginSubtitle": (f"Your live view of {use_case.lower()} — with AI-powered answers "
|
| 273 |
+
f"on real data. Built on {company}, powered by ThoughtSpot."),
|
| 274 |
+
"loginStats": [{"value": "Live", "label": "Operational Data"},
|
| 275 |
+
{"value": "AI", "label": "Natural-Language Answers"},
|
| 276 |
+
{"value": "Real-time", "label": "Decision Support"}],
|
| 277 |
+
"loginCardSubtitle": "Continue with your ThoughtSpot session",
|
| 278 |
+
"loginDemoNoteHtml": ("<strong>Continue</strong> using your existing ThoughtSpot "
|
| 279 |
+
"session on <code>{host}</code>. This origin must be "
|
| 280 |
+
"CORS-allowlisted on that cluster for data to load."),
|
| 281 |
+
"platformNav": ["Overview", "Analytics", "Insights"],
|
| 282 |
+
"tabs": {"myReports": "My Reports", "analytics": "Analytics", "inline": "Detail",
|
| 283 |
+
"action": "Actions", "ask": f"Ask {ai_name}", "spotter": "Spotter"},
|
| 284 |
+
"analyticsSubtitle": f"Your live {use_case.lower()} dashboard",
|
| 285 |
+
"spotterSubtitle": f"Ask questions of your {company} data in natural language.",
|
| 286 |
+
"inline": {"subtitleReady": "Select a row to view its insights",
|
| 287 |
+
"subtitleIdle": "Key metrics across your portfolio",
|
| 288 |
+
"loading": "Loading…", "errorTitle": "Couldn't load data",
|
| 289 |
+
"errorMsg": "Unable to load.", "moreLabel": "View insights",
|
| 290 |
+
"hideLabel": "Hide insights"},
|
| 291 |
+
"filters": {"primaryLabel": primary_label, "secondaryLabel": secondary_label},
|
| 292 |
+
"sampleQuestions": samples,
|
| 293 |
+
"askWelcome": (f"Ask me anything about {company} — or ask about your own data "
|
| 294 |
+
"and I will chart it live."),
|
| 295 |
+
"askEmptySub": ("Ask any analytical question. Answers come straight from your live "
|
| 296 |
+
"data with an interactive chart you can drill into."),
|
| 297 |
+
"action": {"label": "Take Action", "subtitlePrefix": "Right-click a row and choose",
|
| 298 |
+
"subtitleSuffix": "to act on it.", "modalTitle": "New Request",
|
| 299 |
+
"modalLead": "Pre-filled for the selected row.",
|
| 300 |
+
"submitLabel": "Submit", "successTitle": "Request submitted"},
|
| 301 |
+
"chatbot": {"welcome": f"Hi! I'm {ai_name}. Ask me about your data.",
|
| 302 |
+
"overview": f"{company}: {use_case}.",
|
| 303 |
+
"greetingExample": samples[0] if samples else "show me the trend",
|
| 304 |
+
"examples": samples[1:3]},
|
| 305 |
+
"trialQuestionTrigger": 3,
|
| 306 |
+
"upgradeReason": f"Upgrade to unlock drill-down, Ask AI and downloads.",
|
| 307 |
+
"analyticsKeywords": sorted({w.lower() for s in samples for w in re.findall(r"[a-z]{4,}", s.lower())}),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def pick_date(dates: list[str], measures: list[str], col_table: dict[str, str]) -> str:
|
| 312 |
+
"""Choose a date column on the SAME fact table as the metrics.
|
| 313 |
+
|
| 314 |
+
A date from a different fact is the cross-fact chasm bug: the filter appears
|
| 315 |
+
to work but slices a table the measures do not live on, so tiles come back
|
| 316 |
+
empty or silently unfiltered. Never fall back to "any date column".
|
| 317 |
+
"""
|
| 318 |
+
if not dates:
|
| 319 |
+
raise PortalBuildError("no date column found on the model — the portal's date filter needs one")
|
| 320 |
+
fact = col_table.get(measures[0], "")
|
| 321 |
+
same_fact = [d for d in dates if col_table.get(d, "") == fact]
|
| 322 |
+
if not same_fact:
|
| 323 |
+
raise PortalBuildError(
|
| 324 |
+
f"no date column on '{fact}', the fact holding the chosen metrics "
|
| 325 |
+
f"({measures[0]}). Dates found: "
|
| 326 |
+
+ ", ".join(f"{d} on {col_table.get(d, '?')}" for d in dates)
|
| 327 |
+
+ ". Pick metrics from a dated fact, or add a date to that fact — "
|
| 328 |
+
"using another fact's date silently filters the wrong table."
|
| 329 |
+
)
|
| 330 |
+
return same_fact[0]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def build_spec(*, company, use_case, host, liveboard, model, attributes, measures,
|
| 334 |
+
dates, primary, accent, logo_rel, website, col_table) -> dict:
|
| 335 |
+
fp, fs = pick_filters(attributes)
|
| 336 |
+
if len(measures) < 2:
|
| 337 |
+
raise PortalBuildError(f"model has only {len(measures)} measure(s); need at least 2 for the metrics")
|
| 338 |
+
date_col = pick_date(dates, measures, col_table)
|
| 339 |
+
segments = [a for a in attributes if any(h in a.lower() for h in SEGMENT_HINTS)]
|
| 340 |
+
ai_name = f"{company.split()[0]} AI"
|
| 341 |
+
samples = [f"{measures[0]} by {fp[0]}", f"{measures[1]} by {fs[0]}",
|
| 342 |
+
f"{measures[0]} over time", f"{measures[1]} by {segments[0] if segments else fp[0]}"]
|
| 343 |
+
return {
|
| 344 |
+
"client": camel_token(company),
|
| 345 |
+
"slug": re.sub(r"[^a-z0-9]+", "-", company.lower()).strip("-"),
|
| 346 |
+
"host": host,
|
| 347 |
+
"auth": "none",
|
| 348 |
+
"ids": {"analyticsLiveboard": liveboard, "model": model,
|
| 349 |
+
"inlineLiveboard": liveboard, "actionLiveboard": liveboard,
|
| 350 |
+
"actionViz": liveboard},
|
| 351 |
+
"action": {"id": "take-action", "name": "Take Action"},
|
| 352 |
+
"columns": {
|
| 353 |
+
"inlineName": fp,
|
| 354 |
+
"inlineMetrics": metric_spec(measures),
|
| 355 |
+
"filterPrimary": fp,
|
| 356 |
+
"filterSecondary": fs,
|
| 357 |
+
"date": date_col,
|
| 358 |
+
"hierarchy": {"segment": segments[0] if segments else fp[0],
|
| 359 |
+
"rep": fp[0], "cadence": fs[0]},
|
| 360 |
+
},
|
| 361 |
+
"logo": {"svgPath": logo_rel},
|
| 362 |
+
"favicon": ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" '
|
| 363 |
+
f'height="32"><rect width="32" height="32" rx="7" fill="{primary}"/>'
|
| 364 |
+
f'<circle cx="16" cy="16" r="7" fill="{accent}"/></svg>'),
|
| 365 |
+
"theme": theme(primary, accent),
|
| 366 |
+
**swaps(primary, accent),
|
| 367 |
+
"features": {"askMode": "both", "inline": False, "action": False,
|
| 368 |
+
"monetize": False, "tiers": False, "pinning": False},
|
| 369 |
+
"content": content(company, use_case, ai_name, website, fp[0], fs[0], samples),
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# ---------------------------------------------------------------- build steps
|
| 374 |
+
|
| 375 |
+
def wordmark(company: str, primary: str, path: Path) -> None:
|
| 376 |
+
label = company.upper()[:26]
|
| 377 |
+
width = 40 + len(label) * 11
|
| 378 |
+
path.write_text(
|
| 379 |
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} 40" '
|
| 380 |
+
f'width="{width}" height="40"><text x="4" y="27" font-family="Inter, '
|
| 381 |
+
f'system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0.5" '
|
| 382 |
+
f'style="fill:{primary}">{label}</text></svg>\n'
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _tool(name: str) -> str:
|
| 387 |
+
"""Absolute path to node/npm. Containers often lack them on the default PATH."""
|
| 388 |
+
found = shutil.which(name) or shutil.which(name, path="/usr/bin:/usr/local/bin:"
|
| 389 |
+
f"{os.path.expanduser('~/.node/bin')}")
|
| 390 |
+
if not found:
|
| 391 |
+
raise PortalBuildError(
|
| 392 |
+
f"'{name}' is not available. The portal build needs Node to run the codemod "
|
| 393 |
+
"and vite. In the MCP Space this comes from the Dockerfile."
|
| 394 |
+
)
|
| 395 |
+
return found
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def run(cmd: list[str], cwd: Path) -> None:
|
| 399 |
+
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
| 400 |
+
if p.returncode != 0:
|
| 401 |
+
raise PortalBuildError(
|
| 402 |
+
f"command failed: {' '.join(cmd)}\n{p.stdout[-1500:]}\n{p.stderr[-1500:]}")
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def patch_dangling_imports(app_dir: Path) -> None:
|
| 406 |
+
"""The codemod's tab pruning deletes tab FILES but leaves their imports.
|
| 407 |
+
|
| 408 |
+
With inline/action off it removes Carriers.tsx / Capacity.tsx plus their JSX
|
| 409 |
+
and nav entries, but not the `import` lines in App.tsx nor the now-unused
|
| 410 |
+
lucide icons in TopBar.tsx — and `tsc --noUnusedLocals` fails on both.
|
| 411 |
+
"""
|
| 412 |
+
app = app_dir / "src" / "App.tsx"
|
| 413 |
+
if app.exists():
|
| 414 |
+
kept = [ln for ln in app.read_text().splitlines(keepends=True)
|
| 415 |
+
if not re.match(r"^import (Carriers|Capacity) from", ln)]
|
| 416 |
+
app.write_text("".join(kept))
|
| 417 |
+
top = app_dir / "src" / "components" / "TopBar.tsx"
|
| 418 |
+
if top.exists():
|
| 419 |
+
kept = [ln for ln in top.read_text().splitlines(keepends=True)
|
| 420 |
+
if not re.match(r"^\s*(Truck|Activity),\s*$", ln)]
|
| 421 |
+
top.write_text("".join(kept))
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def deploy_space(dist: Path, owner: str, space: str) -> str:
|
| 425 |
+
from huggingface_hub import HfApi
|
| 426 |
+
|
| 427 |
+
token = os.getenv("HF_TOKEN")
|
| 428 |
+
if not token:
|
| 429 |
+
raise PortalBuildError("HF_TOKEN is not set in .env — cannot deploy the Space")
|
| 430 |
+
api = HfApi(token=token)
|
| 431 |
+
repo_id = f"{owner}/{space}"
|
| 432 |
+
api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="static",
|
| 433 |
+
private=False, exist_ok=True)
|
| 434 |
+
api.upload_folder(folder_path=str(dist), repo_id=repo_id, repo_type="space",
|
| 435 |
+
commit_message="portal build")
|
| 436 |
+
return f"https://{owner.replace('.', '-')}-{space}.static.hf.space".replace("_", "-")
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# ---------------------------------------------------------------- public API
|
| 441 |
+
|
| 442 |
+
def build_portal(
|
| 443 |
+
*,
|
| 444 |
+
company_name: str,
|
| 445 |
+
liveboard_guid: str,
|
| 446 |
+
model_guid: str = "",
|
| 447 |
+
use_case: str = "Analytics",
|
| 448 |
+
ts_env: str = "sebe",
|
| 449 |
+
primary: str = "#12283f",
|
| 450 |
+
accent: str = "#2f6fb0",
|
| 451 |
+
website: str = "",
|
| 452 |
+
logo_path: str = "",
|
| 453 |
+
space_name: str = "",
|
| 454 |
+
hf_owner: str = "thoughtspot-demoprep",
|
| 455 |
+
work_dir: str = "",
|
| 456 |
+
as_user: str = "",
|
| 457 |
+
deploy: bool = True,
|
| 458 |
+
on_progress=None,
|
| 459 |
+
) -> dict:
|
| 460 |
+
"""Build (and optionally deploy) a portal for one deployed liveboard.
|
| 461 |
+
|
| 462 |
+
Returns a dict with url/spec/paths. Raises PortalBuildError with a specific
|
| 463 |
+
message on any failure — no silent fallbacks.
|
| 464 |
+
|
| 465 |
+
work_dir defaults to a fresh temp directory, because the repo tree is not
|
| 466 |
+
writable in the MCP Space (see module docstring).
|
| 467 |
+
"""
|
| 468 |
+
def say(msg: str) -> None:
|
| 469 |
+
if on_progress:
|
| 470 |
+
on_progress(msg)
|
| 471 |
+
|
| 472 |
+
if not (company_name or "").strip():
|
| 473 |
+
raise PortalBuildError("company_name is required")
|
| 474 |
+
if not (liveboard_guid or "").strip():
|
| 475 |
+
raise PortalBuildError("liveboard_guid is required")
|
| 476 |
+
for label, hexval in (("primary", primary), ("accent", accent)):
|
| 477 |
+
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", hexval or ""):
|
| 478 |
+
raise PortalBuildError(f"{label} colour must be a 6-digit hex, got {hexval!r}")
|
| 479 |
+
if not CODEMOD.exists():
|
| 480 |
+
raise PortalBuildError(f"codemod missing at {CODEMOD}")
|
| 481 |
+
|
| 482 |
+
work = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="portal-"))
|
| 483 |
+
work.mkdir(parents=True, exist_ok=True)
|
| 484 |
+
|
| 485 |
+
say(f"authenticating to {ts_env}" + (f" as {as_user}" if as_user else ""))
|
| 486 |
+
client, base = ts_client(ts_env, as_user)
|
| 487 |
+
|
| 488 |
+
guid = model_guid or resolve_model(client, base, liveboard_guid)
|
| 489 |
+
if not model_guid:
|
| 490 |
+
say(f"model derived from liveboard: {guid}")
|
| 491 |
+
|
| 492 |
+
say("reading model columns")
|
| 493 |
+
attributes, measures, dates, col_table = model_columns(client, base, guid)
|
| 494 |
+
say(f"{len(attributes)} attributes, {len(measures)} measures, {len(dates)} date column(s)")
|
| 495 |
+
|
| 496 |
+
logo_name = "brand-logo.svg"
|
| 497 |
+
if logo_path:
|
| 498 |
+
shutil.copy(logo_path, work / logo_name)
|
| 499 |
+
else:
|
| 500 |
+
wordmark(company_name, primary, work / logo_name)
|
| 501 |
+
|
| 502 |
+
host = base.replace("https://", "").replace("http://", "")
|
| 503 |
+
spec = build_spec(
|
| 504 |
+
company=company_name, use_case=use_case, host=host, liveboard=liveboard_guid,
|
| 505 |
+
model=guid, attributes=attributes, measures=measures, dates=dates,
|
| 506 |
+
primary=primary, accent=accent, logo_rel=logo_name, col_table=col_table,
|
| 507 |
+
website=website or f"https://{host}",
|
| 508 |
+
)
|
| 509 |
+
(work / "spec.json").write_text(json.dumps(spec, indent=2))
|
| 510 |
+
say(f"spec: filters {spec['columns']['filterPrimary'][0]} / "
|
| 511 |
+
f"{spec['columns']['filterSecondary'][0]}, date {spec['columns']['date']}")
|
| 512 |
+
|
| 513 |
+
say("running codemod")
|
| 514 |
+
run([_tool("node"), str(CODEMOD), "spec.json"], cwd=work)
|
| 515 |
+
app_dir = work / f"{spec['slug']}-tse"
|
| 516 |
+
if not app_dir.is_dir():
|
| 517 |
+
raise PortalBuildError(f"codemod did not produce {app_dir}")
|
| 518 |
+
|
| 519 |
+
patch_dangling_imports(app_dir)
|
| 520 |
+
|
| 521 |
+
say("building (vite)")
|
| 522 |
+
run([_tool("npm"), "run", "build"], cwd=app_dir)
|
| 523 |
+
dist = app_dir / "dist"
|
| 524 |
+
if not (dist / "index.html").exists():
|
| 525 |
+
raise PortalBuildError(f"build produced no dist/index.html in {app_dir}")
|
| 526 |
+
|
| 527 |
+
out = {
|
| 528 |
+
"status": "built",
|
| 529 |
+
"spec": spec,
|
| 530 |
+
"work_dir": str(work),
|
| 531 |
+
"app_dir": str(app_dir),
|
| 532 |
+
"dist": str(dist),
|
| 533 |
+
"liveboard_guid": liveboard_guid,
|
| 534 |
+
"model_guid": guid,
|
| 535 |
+
"resolved_columns": {
|
| 536 |
+
"filter_primary": spec["columns"]["filterPrimary"][0],
|
| 537 |
+
"filter_secondary": spec["columns"]["filterSecondary"][0],
|
| 538 |
+
"date": spec["columns"]["date"],
|
| 539 |
+
"metrics": [m["candidates"][0] for m in spec["columns"]["inlineMetrics"]],
|
| 540 |
+
},
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
if not deploy or not space_name:
|
| 544 |
+
out["message"] = "Built locally; not deployed (no space_name)."
|
| 545 |
+
return out
|
| 546 |
+
|
| 547 |
+
say(f"deploying to {hf_owner}/{space_name}")
|
| 548 |
+
url = deploy_space(dist, hf_owner, space_name)
|
| 549 |
+
hostname = url.replace("https://", "")
|
| 550 |
+
out.update({
|
| 551 |
+
"status": "success",
|
| 552 |
+
"url": url,
|
| 553 |
+
"space": f"{hf_owner}/{space_name}",
|
| 554 |
+
"cors_setup_required": {
|
| 555 |
+
"cluster": host,
|
| 556 |
+
"add_exact_host": hostname,
|
| 557 |
+
"where": "Develop -> Customizations -> Security Settings -> "
|
| 558 |
+
"CORS whitelisted domains",
|
| 559 |
+
"note": "Wildcards such as *.static.hf.space are rejected by validation. "
|
| 560 |
+
"Without this entry the portal renders but its filters and "
|
| 561 |
+
"metrics cannot load data.",
|
| 562 |
+
},
|
| 563 |
+
"auth_note": "auth=none — viewers need an active ThoughtSpot session on that cluster.",
|
| 564 |
+
})
|
| 565 |
+
return out
|
demoprep_app/portal/references/SKILL.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: rebrand-thoughtspot-portal
|
| 3 |
+
description: Guided builder that turns the reusable Vite + React + @thoughtspot/visual-embed-sdk portal template into a fully-skinned client app — brand, theme, data, tabs, custom actions, monetization and user tiers — from a plain-language interview. Use when someone wants to build / skin / white-label / clone a ThoughtSpot embedded analytics portal for a company (e.g. "build the portal for Acme", "skin the app for <client>", "make a ThoughtSpot demo app for X"). Walks a branching question tree (no coding required from the user), then clones the template and wires everything up, building and verifying at each step. Not for building a brand-new app architecture from scratch, or non-ThoughtSpot apps.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Build a client ThoughtSpot portal (guided)
|
| 7 |
+
|
| 8 |
+
You are running a **non-technical intake**: the user answers plain-language
|
| 9 |
+
questions, you do all the code. Flow: **interview → build → verify → (ask before) deploy.**
|
| 10 |
+
Never make the user touch code or GUIDs beyond pasting the values you ask for.
|
| 11 |
+
|
| 12 |
+
## The base template
|
| 13 |
+
|
| 14 |
+
The base app is **bundled inside this skill** at `template-tse/` (next to this
|
| 15 |
+
`SKILL.md`) — a brand-neutral ("Northwind") build that already contains every
|
| 16 |
+
feature toggleable: Analytics liveboard, inline-insights list tab, custom-action
|
| 17 |
+
viz tab, standalone Spotter tab, "fancy" Ask-AI chat, monetization paywall,
|
| 18 |
+
Premium/Basic tiers, host filters, light+dark theme. You keep what the answers ask
|
| 19 |
+
for and strip the rest.
|
| 20 |
+
|
| 21 |
+
**First-time setup: none needed.** The bundled template's deps are installed
|
| 22 |
+
**automatically** by the codemod on the first build (it detects a missing
|
| 23 |
+
`node_modules` and runs `npm install` once, looking in `~/.node/bin` if npm isn't on
|
| 24 |
+
PATH). The codemod resolves the template relative to itself, so the skill works from
|
| 25 |
+
any project directory. Each generated `<slug>-tse/` app symlinks its `node_modules` to
|
| 26 |
+
the bundled template's, so nothing to install per app.
|
| 27 |
+
|
| 28 |
+
The codemod copies the template to `<client>-tse/` (kebab-case) in the current project,
|
| 29 |
+
excluding `node_modules dist .vercel .env.local`.
|
| 30 |
+
|
| 31 |
+
## Step 0 — Show the "gather-first" card (before any question)
|
| 32 |
+
|
| 33 |
+
People stall mid-interview when a question asks for a GUID they don't have open. So
|
| 34 |
+
**before** asking anything, post this checklist in one plain-language message and let
|
| 35 |
+
them collect it all at once. Keep it short — don't explain fields yet, just list what
|
| 36 |
+
to have ready:
|
| 37 |
+
|
| 38 |
+
> **Before we start, grab these — you'll paste them as we go (takes ~2 min):**
|
| 39 |
+
> 1. **Your ThoughtSpot host URL** (e.g. `https://your-co.thoughtspot.cloud`)
|
| 40 |
+
> 2. **The main dashboard's Liveboard ID** — open the liveboard; it's the long ID in
|
| 41 |
+
> the URL after `/pinboard/` or `/liveboard/`.
|
| 42 |
+
> 3. **Your data model / worksheet ID** — the source the dashboard is built on.
|
| 43 |
+
> 4. **A brand screenshot** — your website, product UI, or logo. I'll pull the colors
|
| 44 |
+
> from it. (A hex color works too if you have no image.)
|
| 45 |
+
> 5. *(optional)* Any extra Liveboard/viz IDs if you want more than one tab.
|
| 46 |
+
> 6. Your **logo** file (SVG or PNG), or say the word and I'll generate a wordmark.
|
| 47 |
+
>
|
| 48 |
+
> Building a demo with no real cluster? Just say so — I'll make up realistic values.
|
| 49 |
+
|
| 50 |
+
Wait for a "ready" (or their first pastes), then start the interview. Don't block on
|
| 51 |
+
it — if they'd rather answer as they go, proceed; the card just prevents the ambush.
|
| 52 |
+
|
| 53 |
+
## Step 1 — Run the interview (the question tree)
|
| 54 |
+
|
| 55 |
+
Ask with `AskUserQuestion`, **one section per round**, offering sensible defaults
|
| 56 |
+
and only expanding a branch when its gate is "yes". Collect answers into the
|
| 57 |
+
**spec** (schema at the bottom). Free-text values (GUIDs, column names, a pasted
|
| 58 |
+
screenshot) you request in a normal message.
|
| 59 |
+
|
| 60 |
+
**Sequencing rule (important):** never fire `AskUserQuestion` in the *same turn* as
|
| 61 |
+
a free-text request — the popup blocks the text box, so the user can't paste. When a
|
| 62 |
+
section has both, **ask the free-text first, wait for the reply, then** pop the
|
| 63 |
+
choice(s). Batch multiple choices of the same section into one popup. If a section is
|
| 64 |
+
purely choices, popup directly; if purely free-text, just ask in the message.
|
| 65 |
+
|
| 66 |
+
Walk the tree in this order. **Section 0 comes first — it prunes the tree:**
|
| 67 |
+
|
| 68 |
+
0. **Basic or Advanced demo?** — *Basic* = a quick demo (Analytics dashboards +
|
| 69 |
+
Spotter + Ask-AI + theme). *Advanced* = the full build. **If Basic, skip
|
| 70 |
+
sections 3, 4 and 6 entirely** (no inline tab, no custom-action tab, no
|
| 71 |
+
Add-Report). Record as `mode`.
|
| 72 |
+
1. **Brand** — company name; AI assistant name (default "Ask <Company> AI");
|
| 73 |
+
one-line "what they do"; website URL; **ask them to attach the logo**
|
| 74 |
+
(SVG preferred, PNG fine) — or offer to generate a wordmark if they have none.
|
| 75 |
+
2. **Data & filters** — ThoughtSpot host URL; main liveboard GUID; model/worksheet
|
| 76 |
+
GUID; **auth** (None = rides existing session · Basic = typed creds · Trusted =
|
| 77 |
+
advanced); and **which columns filter the main liveboard** (multiselect) + an
|
| 78 |
+
optional **date column**.
|
| 79 |
+
3. **Inline-insights tab?** *(advanced only)* → if yes: inline **liveboard** GUID
|
| 80 |
+
(confirm it's a *liveboard*, not a viz/answer), tab name, the list's **name
|
| 81 |
+
column**, and up to 3 **metric columns**.
|
| 82 |
+
4. **Custom-action workflow?** *(advanced only; optional — ask first, skip entirely
|
| 83 |
+
if no.)* Only if yes: **viz** GUID + its **liveboard** GUID, action label, tab name, and
|
| 84 |
+
**— required —** a description of the workflow: what screen/modal opens on click, which
|
| 85 |
+
row fields pre-fill which inputs, and what "Submit" does. **You build exactly that screen**
|
| 86 |
+
(rebuilding the scaffold modal); do NOT fall back to the generic "Request Bid" form.
|
| 87 |
+
A screenshot of the target screen is ideal — ask for one. If the user can't describe a
|
| 88 |
+
workflow, ask whether a simple labeled form is acceptable or the action should be dropped.
|
| 89 |
+
5. **Ask-AI** — Normal Spotter (standalone), Fancy chat, or both.
|
| 90 |
+
- **Floating chatbot in the bottom-right corner?** (yes/no)
|
| 91 |
+
- **Monetization?** → if yes: which question number triggers the paywall.
|
| 92 |
+
- **Feature gating (tiers)?** → if yes: which actions Basic loses (drill-down,
|
| 93 |
+
Ask AI, downloads, …).
|
| 94 |
+
6. **Pinning / "Add Report"?** *(advanced only)* — the Analytics "Add Report"
|
| 95 |
+
split-panel that lets a user build an answer (Report Builder / Spotter) and **pin
|
| 96 |
+
it to the dashboard**. If yes, **warn**: the Report Builder (`SearchEmbed`) renders
|
| 97 |
+
poorly in **dark** theme — suggest defaulting to light, or accepting the caveat. If
|
| 98 |
+
no, strip the Add-Report menu, the pin flow, and `PinModal`.
|
| 99 |
+
7. **Theme** — **REQUIRED FIRST: explicitly ask the user to UPLOAD a screenshot for
|
| 100 |
+
color reference — and WAIT for it before anything else in this section.** Say it in
|
| 101 |
+
plain words, e.g. *"Please upload a screenshot for reference (your website, product UI,
|
| 102 |
+
a marketing shot, or your logo) and I'll derive the palette from it."* Do NOT jump to a
|
| 103 |
+
color-picker or "what's your primary color?" — the screenshot is the reference. A pasted
|
| 104 |
+
**URL** is an acceptable substitute; a **named color** is a last resort only when the
|
| 105 |
+
user genuinely has no image at all (and even then, ask). Derive the whole palette from
|
| 106 |
+
the uploaded image. Then: light / dark / both (+ default); font(s) for titles vs body;
|
| 107 |
+
confirm the primary/accent you read from the image; any specific asks (gradients, hues,
|
| 108 |
+
"make titles/KPIs pop").
|
| 109 |
+
|
| 110 |
+
There is **no deploy question** — always **build + run locally** by default. Only
|
| 111 |
+
deploy to Vercel if the user explicitly asks for it afterward.
|
| 112 |
+
|
| 113 |
+
### Completeness gate — do NOT skip, do NOT fabricate
|
| 114 |
+
|
| 115 |
+
Before you build, you MUST have an explicit answer for **every section 0–7**, including the
|
| 116 |
+
free-text ones. Never invent or silently default a value the user could provide.
|
| 117 |
+
|
| 118 |
+
- **Section 2 (Data) is REQUIRED free-text — actually ask for it and wait.** You must
|
| 119 |
+
collect the host URL, the Analytics liveboard GUID, the model/worksheet GUID, (if inline
|
| 120 |
+
enabled) the inline liveboard GUID, (if action enabled) the action viz + its liveboard
|
| 121 |
+
GUID, the filter columns, and the date column. **Do NOT invent GUIDs or a host.** If the
|
| 122 |
+
user is building a fictitious/demo app, ask explicitly "want me to make these up?" — get
|
| 123 |
+
a yes per-value; don't assume it.
|
| 124 |
+
- **Section 7 (Theme) — ask for BOTH the screenshot/URL AND the font** (a Google font by
|
| 125 |
+
name, or an attached file). Never pick a font yourself without asking.
|
| 126 |
+
- Batching choice-questions into one `AskUserQuestion` popup is fine, but it must **not
|
| 127 |
+
drop the free-text asks** that belong to the same section (Data GUIDs, font, columns).
|
| 128 |
+
|
| 129 |
+
Then **echo the COMPLETE spec back in plain language** — every field, section by section,
|
| 130 |
+
flagging anything the user let you make up — and get an explicit **"go"** before building.
|
| 131 |
+
If you catch yourself about to fabricate a value or skip a section, stop and ask instead.
|
| 132 |
+
|
| 133 |
+
## Step 2 — Build (spec-driven: interview → spec.json → codemod → hand-finish)
|
| 134 |
+
|
| 135 |
+
The interview becomes a **`spec.json`**; a codemod does the mechanical ~90% in under a
|
| 136 |
+
second; you hand-finish the few things a script can't.
|
| 137 |
+
|
| 138 |
+
1. **Write `spec.json`.** Copy **`references/spec.example.json`** (a complete working
|
| 139 |
+
example) and edit every value per the interview. Field-by-field mapping + the swap-array
|
| 140 |
+
rules are in **`references/spec-schema.md`**. Derive the `theme.light`/`theme.dark` token
|
| 141 |
+
dicts from the brand screenshot/URL; in the `embedSwaps`/`greenSwaps`/`cssSwaps` arrays
|
| 142 |
+
change only the **right-hand** (brand) values — the left-hand hexes are fixed template
|
| 143 |
+
constants.
|
| 144 |
+
2. **Run the codemod** from the project root (where you want the app written):
|
| 145 |
+
```bash
|
| 146 |
+
node .claude/skills/rebrand-thoughtspot-portal/scripts/apply-spec.mjs spec.json
|
| 147 |
+
```
|
| 148 |
+
It clones the bundled `template-tse` → `<slug>-tse/`, renames the brand, writes `content.ts`, patches
|
| 149 |
+
`config.ts` (host/GUIDs/columns/embed theme), switches auth, regenerates the `globals.css`
|
| 150 |
+
theme tokens + `@font-face`, drops in logo/favicon/font, and prunes the tabs `features`
|
| 151 |
+
turns off.
|
| 152 |
+
3. **Hand-finish what the codemod can't** (it prints reminders):
|
| 153 |
+
- **Custom-action modal** — if a workflow was described, **MANDATORY**: rebuild the
|
| 154 |
+
scaffold modal to match it (build-playbook §5). Never leave the generic Request Bid form.
|
| 155 |
+
- Any bespoke UI the user flagged (custom tables, live lookups).
|
| 156 |
+
(Tabs and the monetize/tiers/pinning toggles are handled by the codemod — nothing to do.)
|
| 157 |
+
4. **Build:** `export PATH="$HOME/.node/bin:$PATH" && (cd <slug>-tse && npm run build)`.
|
| 158 |
+
Fix any `tsc` errors and rebuild until clean.
|
| 159 |
+
|
| 160 |
+
**`references/build-playbook.md`** is the reference for WHAT each field maps to and the
|
| 161 |
+
non-obvious rules (host protection, solid-color embed buttons, masterpieces flag,
|
| 162 |
+
liveboard-vs-viz, `Action` enum names, candidate-column fallback) — consult it when a
|
| 163 |
+
codemod result looks off or when hand-editing. (The playbook's per-file manual steps still
|
| 164 |
+
work as a fallback if the codemod can't run.)
|
| 165 |
+
|
| 166 |
+
## Step 3 — Verify, run, (ask to) deploy
|
| 167 |
+
|
| 168 |
+
- `npm run dev` and give the user the localhost URL.
|
| 169 |
+
- Print a **spec recap** + a **"verify on your cluster"** list: exact column names
|
| 170 |
+
the candidate-lists resolved, the CSP/CORS **allowlist** step for the domain, and
|
| 171 |
+
the auth/session requirement.
|
| 172 |
+
- Deploy to Vercel only if the user asked (see the playbook's deploy section).
|
| 173 |
+
|
| 174 |
+
## Spec schema
|
| 175 |
+
|
| 176 |
+
The interview's answers are written to a **`spec.json`** consumed by the codemod. The full
|
| 177 |
+
field-by-field schema, the interview→field mapping, and the fixed-LHS swap rules live in
|
| 178 |
+
**`references/spec-schema.md`**; **`references/spec.example.json`** is a complete worked
|
| 179 |
+
example to copy and edit. (Deploy is always LOCAL by default — not interviewed; Vercel only
|
| 180 |
+
on an explicit later ask.)
|
| 181 |
+
|
| 182 |
+
## Rules of engagement
|
| 183 |
+
- Confirm ambiguous ThoughtSpot IDs by **type** (liveboard / viz / saved answer) — the
|
| 184 |
+
#1 source of blank embeds.
|
| 185 |
+
- Prefer **candidate-column lists** over a single guessed name; the app falls back gracefully.
|
| 186 |
+
- Keep the user in plain language; you own the terminal, the build, and the fixes.
|
demoprep_app/portal/references/build-playbook.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build playbook — spec → edits
|
| 2 |
+
|
| 3 |
+
Deterministic steps to turn the interview **spec** into a working `<client>-tse/`.
|
| 4 |
+
Run `npm run build` (with `export PATH="$HOME/.node/bin:$PATH"`) after each stage.
|
| 5 |
+
The template brand token is **`Northwind`** (casings: `Northwind` / `NORTHWIND` /
|
| 6 |
+
`northwind`); the placeholder data tokens are `YOUR-CLUSTER.thoughtspot.cloud` and
|
| 7 |
+
`REPLACE_*_GUID`.
|
| 8 |
+
|
| 9 |
+
**Mode:** `basic` = strip the inline tab, the custom-action tab, and Add-Report
|
| 10 |
+
(keep My Reports · Analytics · Ask-AI · Spotter · theme). `advanced` = per the
|
| 11 |
+
answers. Deploy is always **local** (`npm run dev`) unless the user later asks for Vercel.
|
| 12 |
+
|
| 13 |
+
## 0. Clone (manual fallback — the codemod does this for you)
|
| 14 |
+
The template is bundled inside the skill. From the project root:
|
| 15 |
+
```bash
|
| 16 |
+
TPL=.claude/skills/rebrand-thoughtspot-portal/template-tse
|
| 17 |
+
rsync -a --exclude node_modules --exclude dist --exclude .vercel --exclude .env.local \
|
| 18 |
+
"$TPL"/ <client>-tse/
|
| 19 |
+
```
|
| 20 |
+
Build locally by symlinking deps to the bundled template's:
|
| 21 |
+
`ln -s "$(cd "$TPL" && pwd)/node_modules" <client>-tse/node_modules` (remove before any commit).
|
| 22 |
+
|
| 23 |
+
## 1. Rebrand (Northwind → <Client>)
|
| 24 |
+
Replace in all casings, then rename the two brand-named files. **zsh does not
|
| 25 |
+
word-split an unquoted `$var`** — always use `find -exec … {} +`, never
|
| 26 |
+
`perl … $(find …)`.
|
| 27 |
+
```bash
|
| 28 |
+
PERL='s/Northwind/<Client>/g; s/NORTHWIND/<CLIENT>/g; s/northwind/<client>/g;'
|
| 29 |
+
find src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) -exec perl -0pi -e "$PERL" {} +
|
| 30 |
+
perl -0pi -e "$PERL" index.html
|
| 31 |
+
perl -0pi -e 's/northwind-thoughtspot-portal/<client>-thoughtspot-portal/g' package.json
|
| 32 |
+
mv src/components/NorthwindLogo.tsx src/components/<Client>Logo.tsx
|
| 33 |
+
mv src/tabs/AskNorthwind.tsx src/tabs/Ask<Client>.tsx
|
| 34 |
+
```
|
| 35 |
+
Display casing: use title-case for the visible brand and "Ask <Client> AI". If the
|
| 36 |
+
client brand is stylized ALL-CAPS, do a targeted follow-up replace on visible
|
| 37 |
+
strings only (not const names). Re-grep for `northwind` — nothing should remain.
|
| 38 |
+
|
| 39 |
+
**Logo:** if the user attached one, write it to `src/assets/<client>-logo.svg`
|
| 40 |
+
(make the wordmark `fill="currentColor"` so it flips with the theme; keep one
|
| 41 |
+
accent color fixed) and a 32×32 `public/<client>-icon.svg` favicon. Otherwise keep
|
| 42 |
+
the generated wordmark. The import paths were already rewritten by the rename pass.
|
| 43 |
+
|
| 44 |
+
## 2. Data (`src/config.ts`)
|
| 45 |
+
Replace the placeholders with the spec:
|
| 46 |
+
- `THOUGHTSPOT_HOST` ← host
|
| 47 |
+
- `ANALYTICS_LIVEBOARD_ID` ← main liveboard GUID
|
| 48 |
+
- `CADENCE_WORKSHEET_ID` **and** `WORKSHEET_ID` ← model/worksheet GUID
|
| 49 |
+
- `INLINE_INSIGHTS_LIVEBOARD_ID` ← inline liveboard GUID (only if inline enabled)
|
| 50 |
+
- `CAPACITY_LIVEBOARD_ID` / `CAPACITY_VIZ_ID` ← the action viz's liveboard + viz (only if action enabled)
|
| 51 |
+
|
| 52 |
+
**Confirm each ID's type.** A whole-liveboard GUID passed as `vizId` renders the
|
| 53 |
+
liveboard-empty "Nothing to see yet" state. Inline tab = `liveboardId` only (no
|
| 54 |
+
vizId), filtered by the name column. Action tab = `liveboardId` + `vizId`.
|
| 55 |
+
|
| 56 |
+
**Auth** (`src/lib/thoughtspot.ts` `init`): `none` → `AuthType.None` (rides the
|
| 57 |
+
existing browser session; the login screen is a demo gate — keep the note telling
|
| 58 |
+
users to be signed in to the cluster in another tab). `basic` → `AuthType.Basic`
|
| 59 |
+
with the login form's username/password. `trusted` → wire a token endpoint (advanced).
|
| 60 |
+
|
| 61 |
+
## 3. Columns (candidate lists in `config.ts`)
|
| 62 |
+
Every column the app reads is a **candidate list** (first that returns data wins;
|
| 63 |
+
graceful fallback). Set the user's real names as the first entry, keep sensible fallbacks:
|
| 64 |
+
- `CADENCE_COLUMN_CANDIDATES` ← inline list's **name column** (e.g. carrier/process name)
|
| 65 |
+
- `CADENCE_DETAIL_COLUMNS` ← the inline **metric columns** (label + format `number|text|currency|date` + candidates); keys are arbitrary, up to 3
|
| 66 |
+
- `REGION_COLUMN_CANDIDATES` / `CORRIDOR_COLUMN_CANDIDATES` → rename to the spec's filter columns
|
| 67 |
+
- `DATE_COLUMN` ← the date filter column
|
| 68 |
+
- Org scoping is off by default (`ORG_COLUMN_CANDIDATES = []`); populate only if the client wants a tenant filter.
|
| 69 |
+
|
| 70 |
+
## 4. Tabs / nav (`src/components/TopBar.tsx` + `src/App.tsx`)
|
| 71 |
+
Template tabs: My Reports · Analytics · **Carriers** (inline) · **Capacity** (action) ·
|
| 72 |
+
**Ask <Client> AI** · **Spotter** (standalone). Keep/rename/remove per spec:
|
| 73 |
+
- Left platform nav (`PLATFORM_NAV`) → the client's context words.
|
| 74 |
+
- Rename the inline tab (`carriers`→spec tabName) and action tab (`capacity`→spec tabName).
|
| 75 |
+
- **Inline disabled** → remove the Carriers tab + route + delete `tabs/Carriers.tsx`.
|
| 76 |
+
- **Action disabled** → remove the Capacity tab + route + delete `tabs/Capacity.tsx` + `components/BidModal.tsx`.
|
| 77 |
+
- **Ask-AI mode**: `fancy` keeps `Ask<Client>` chat; `normal` keeps only the `Spotter` tab; `both` keeps both.
|
| 78 |
+
Update `TabId`, the `TABS` array, and the `{tab === …}` routes together; `tsc` catches mismatches.
|
| 79 |
+
|
| 80 |
+
## 5. Custom-action workflow (only if enabled) — `tabs/Capacity.tsx` + `components/BidModal.tsx`
|
| 81 |
+
- `REQUEST_BID_ACTION_ID/NAME` ← the action label.
|
| 82 |
+
- Custom action: `position: CustomActionsPosition.CONTEXTMENU`, `target: CustomActionTarget.VIZ`, scoped via `metadataIds`.
|
| 83 |
+
- `onCustomAction` builds context from the clicked row (`buildSignalContext` in `lib/signalContext.ts` reads `contextMenuPoints.clickedPoint`).
|
| 84 |
+
- **MANDATORY: rebuild the modal to match the workflow the user described — never ship the
|
| 85 |
+
generic "Request Bid" form for a client who described a workflow.** `BidModal.tsx` is a
|
| 86 |
+
*scaffold*, not the deliverable. Rename it to the workflow (e.g. `LeaveRequestModal.tsx`),
|
| 87 |
+
rebuild its fields to match the description (what opens on click, which row fields pre-fill
|
| 88 |
+
which inputs, what "Submit" confirms), and update the action label + tab name. The generic
|
| 89 |
+
bid form is only acceptable when the user explicitly said "any simple form is fine" or
|
| 90 |
+
declined to describe one. If a `spec.json`/codemod scaffolds the app, this modal is the
|
| 91 |
+
one step you STILL implement by hand afterward — the codemod prints a NOTE reminding you.
|
| 92 |
+
- Verify at the end: right-click the viz → the action appears with the user's label → it opens
|
| 93 |
+
*their* described screen (not the bid form), pre-filled from the clicked row.
|
| 94 |
+
|
| 95 |
+
## 6. Ask-AI, chatbot, monetization, tiers
|
| 96 |
+
- **Floating chatbot** (`components/ChatBot.tsx`, mounted in `App.tsx`): keep if the user wants the bottom-right bot; otherwise remove the `<ChatBot …/>` mount.
|
| 97 |
+
- **Fancy Ask tab** copy: welcome, subtitle, and `<Client>_SAMPLE_QUESTIONS` → the client's domain.
|
| 98 |
+
- **Monetization**: paywall is `components/TrialModal.tsx`, shown once on the Nth question (trigger in `tabs/Ask<Client>.tsx`, `if (nextCount === N)`). Set N from spec. The template's modal is already the professional version.
|
| 99 |
+
- **Add Report / pinning** (Analytics tab, `tabs/Analytics.tsx`): the "Add Report"
|
| 100 |
+
split-panel = a Report Builder (`SearchEmbed`) + Spotter answer that pins to the
|
| 101 |
+
dashboard (`HostEvent.Pin`), via `components/PinModal.tsx`. If **not** wanted,
|
| 102 |
+
remove the Add-Report menu button, the `panel` split, the Search/Spotter report
|
| 103 |
+
column, the pin flow, and the `PinModal` import/mount. **Caveat to surface if
|
| 104 |
+
wanted:** `SearchEmbed` (Report Builder) renders poorly in **dark** theme — advise
|
| 105 |
+
defaulting to light, or accept it.
|
| 106 |
+
- **Tiers**: `context/TierContext.tsx` (Premium/Basic switch in TopBar) + `lib/tierActions.ts` `BASIC_DISABLED_ACTIONS`. Map requested features to `Action` enum members — **verify names in the installed SDK** (`grep "enum Action" node_modules/@thoughtspot/visual-embed-sdk/dist/*.d.ts`): it's `Action.AskAi` (not `AISearch`), `Action.DrillDown`, `Action.Download` / `DownloadAsCsv` / `DownloadAsPdf` / `DownloadAsXlsx`, `Action.SpotIQAnalyze`. Pass `disabledActions` + `disabledActionReason` on each liveboard embed. If tiers not wanted, remove the switcher + the disabledActions props.
|
| 107 |
+
|
| 108 |
+
## 7. Theme (`src/styles/globals.css` tokens + `config.ts` embed vars)
|
| 109 |
+
Derive the palette from the **screenshot/URL**. Set:
|
| 110 |
+
- Host tokens on `:root` (light) and `:root[data-theme='dark']`: `--sl-cream`,
|
| 111 |
+
`--sl-white`, `--sl-evergreen` (logo/ink), `--sl-green` (**primary**),
|
| 112 |
+
`--sl-green-dark`, `--sl-mint`, `--sl-coral` (alert), `--sl-ink`, `--sl-muted`, borders.
|
| 113 |
+
- `--<client>-grad` gradient for titles; `.page-title` already clips it.
|
| 114 |
+
- Fonts: swap the Google-Fonts `<link>` in `index.html` and `--font-serif`/`--font-sans`
|
| 115 |
+
+ `TS_FONT_URL`. Titles vs body weights per spec.
|
| 116 |
+
- **Theme mode**: default in `context/ThemeContext.tsx` (`'dark'` or `'light'`); if
|
| 117 |
+
the client is single-theme, still keep both palettes defined.
|
| 118 |
+
- **Dark base is ANY color** (black, navy, red, green…): it's fully token-driven — the
|
| 119 |
+
template has no hardcoded hues. Set the `:root[data-theme='dark']` block
|
| 120 |
+
(`--sl-cream` page bg, `--sl-white` cards, `--sl-ink`/`--sl-muted` text,
|
| 121 |
+
`--sl-green`/`--sl-green-dark` primary, borders) **and `--sl-primary-rgb`** (the
|
| 122 |
+
primary as `r,g,b`) — every glow/rim uses `rgba(var(--sl-primary-rgb), a)` and every
|
| 123 |
+
shadow is neutral slate, so changing those tokens re-themes the whole dark mode.
|
| 124 |
+
- **Embed vars**: retint `TS_CSS_VARIABLES` (light) and `TS_VARS_DARK` (dark).
|
| 125 |
+
|
| 126 |
+
**Embed gotchas (these bit us — do not repeat):**
|
| 127 |
+
- **Buttons must be a SOLID color, never a gradient**, on `--ts-var-*-button-*-background`
|
| 128 |
+
— the SDK applies them as `background-color`, so a gradient renders as invisible/white.
|
| 129 |
+
Put gradients only on host CSS and on customCSS **rules** (`Triumph_EMBED_RULES` →
|
| 130 |
+
rename to `<Client>_EMBED_RULES`), e.g. KPI-tile background and viz-title text.
|
| 131 |
+
- **Dark embed must blend**: set `--ts-var-liveboard-layout-background` / nav / group to
|
| 132 |
+
the app's page background, tiles one step lighter. Purge any leftover green
|
| 133 |
+
(`rgba(27,185,120,…)` ring in `--ts-var-viz-box-shadow`, `#143329` table header).
|
| 134 |
+
- **`isLiveboardMasterpiecesEnabled: true`** must be on **every** LiveboardEmbed
|
| 135 |
+
(it's in `LIVEBOARD_EMBED_FLAGS` — spread it everywhere, incl. the inline tab) or
|
| 136 |
+
custom/BYOC vizzes throw "features disabled by your admin".
|
| 137 |
+
- CustomCSS selectors for KPI tiles / viz titles / the AI-Highlights button are
|
| 138 |
+
version-dependent — they're best-effort; tell the user to Inspect and share a class
|
| 139 |
+
if a surface is missed.
|
| 140 |
+
|
| 141 |
+
## 8. Copy
|
| 142 |
+
Login hero tagline + subtitle (`pages/Login.tsx`), the demo-access note (host name),
|
| 143 |
+
chatbot FAQ/system prompt (`lib/chatbot.ts`), and sample questions — all to the
|
| 144 |
+
client's domain, derived from "what they do".
|
| 145 |
+
|
| 146 |
+
## 9. Verify + deploy
|
| 147 |
+
- `npm run build` clean, then `npm run dev` → give the localhost URL.
|
| 148 |
+
- Recap the spec; list the resolved column names, the **CSP/CORS allowlist** step for
|
| 149 |
+
the deploy domain, and the auth/session requirement.
|
| 150 |
+
- **Deploy only if asked.** Vercel CLI is cached (`npx vercel@latest`, scope
|
| 151 |
+
`koushik426s-projects` — personal can't be passed as `--scope <name>`):
|
| 152 |
+
`vercel project add <name>` → `vercel link --yes --project <name> --scope …` →
|
| 153 |
+
`vercel deploy --prod --yes --scope …`. A project named `<name>` serves
|
| 154 |
+
`<name>.vercel.app`. Remove the `node_modules` symlink before any git commit.
|
demoprep_app/portal/references/intake.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Portal intake (plain English)
|
| 2 |
+
|
| 3 |
+
Answer what you can — anything skipped uses a sensible default, and the assistant
|
| 4 |
+
will ask follow-ups. No technical knowledge needed; just paste the values.
|
| 5 |
+
|
| 6 |
+
## 0. How deep?
|
| 7 |
+
- **Basic** (quick demo: dashboards + Spotter + Ask-AI + theme) or **Advanced**
|
| 8 |
+
(full build)? If Basic, sections 3, 4 and 6 are skipped.
|
| 9 |
+
|
| 10 |
+
## 1. Brand
|
| 11 |
+
- Company name:
|
| 12 |
+
- Name for the AI assistant (default "Ask <Company> AI"):
|
| 13 |
+
- One line on what the company does:
|
| 14 |
+
- Website URL:
|
| 15 |
+
- **Logo** — attach an SVG (preferred) or PNG. No logo? Say "generate one".
|
| 16 |
+
|
| 17 |
+
## 2. Your ThoughtSpot data & filters
|
| 18 |
+
- ThoughtSpot URL (e.g. `your-co.thoughtspot.cloud`):
|
| 19 |
+
- Main dashboard (liveboard) — paste its GUID:
|
| 20 |
+
- Data model / worksheet — paste its GUID:
|
| 21 |
+
- Sign-in: (a) rely on being logged into ThoughtSpot in another tab · (b) type real credentials · (c) advanced/trusted
|
| 22 |
+
- Which fields should filter the main dashboard (e.g. Region, Corridor)?
|
| 23 |
+
- A date field to filter by?
|
| 24 |
+
|
| 25 |
+
## 3. Inline-insights list tab? (a table of items, each opening its own dashboard)
|
| 26 |
+
- Want it? yes / no
|
| 27 |
+
- If yes → dashboard (liveboard) GUID to open inline · tab name · the column of item
|
| 28 |
+
names (e.g. Carrier, Process) · up to 3 numbers to show per row.
|
| 29 |
+
|
| 30 |
+
## 4. Custom-action workflow? (right-click a chart row → run a Triumph-style action) — OPTIONAL
|
| 31 |
+
- Want it? yes / no (if no, we skip this entirely)
|
| 32 |
+
- If yes → visualization GUID + the dashboard it lives on · action label (e.g.
|
| 33 |
+
"Request Bid") · describe what should happen on click and which row details pre-fill it · tab name.
|
| 34 |
+
|
| 35 |
+
## 5. Ask-AI
|
| 36 |
+
- Which style: (a) standalone Spotter · (b) fancy chat experience · (c) both
|
| 37 |
+
- Floating chatbot in the bottom-right corner? yes / no
|
| 38 |
+
- Paywall/monetization popup? yes / no — if yes, after how many questions?
|
| 39 |
+
- Premium vs Basic users? yes / no — if yes, what should Basic NOT be able to do
|
| 40 |
+
(drill-down, Ask AI, downloads, …)?
|
| 41 |
+
|
| 42 |
+
## 6. Pinning / "Add Report"
|
| 43 |
+
- Want the "Add Report" feature — build a chart in-app and pin it to the dashboard? yes / no
|
| 44 |
+
(Heads-up: the Report Builder doesn't render well in **dark** mode — light theme recommended if you enable it.)
|
| 45 |
+
|
| 46 |
+
## 7. Look & feel
|
| 47 |
+
- **Attach a screenshot of the brand's site** and/or its URL (colors are taken from it).
|
| 48 |
+
- Light, dark, or both? Which is the default?
|
| 49 |
+
- Fonts (title vs body), if specific.
|
| 50 |
+
- Primary color, and any specific asks (gradients, hues, "make the titles/KPIs pop").
|
| 51 |
+
|
| 52 |
+
_The app is built and run **locally** by default — ask afterward if you want it deployed to a public URL._
|
demoprep_app/portal/references/spec-schema.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# spec.json — schema + interview mapping
|
| 2 |
+
|
| 3 |
+
The interview produces a **`spec.json`**; the codemod `scripts/apply-spec.mjs` turns it
|
| 4 |
+
into a working `<slug>-tse/` app in well under a second. **Copy
|
| 5 |
+
`references/spec.example.json`** (a complete, working InTime example) and edit every
|
| 6 |
+
value per the interview — do not hand-write from scratch.
|
| 7 |
+
|
| 8 |
+
Run from the project root (the codemod finds its bundled `template-tse/` relative to
|
| 9 |
+
itself, and resolves your `spec.json` asset paths against the current directory):
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
node .claude/skills/rebrand-thoughtspot-portal/scripts/apply-spec.mjs spec.json
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## Top-level fields
|
| 16 |
+
|
| 17 |
+
| Field | From interview | Notes |
|
| 18 |
+
|---|---|---|
|
| 19 |
+
| `client` | Brand name, Title-case | e.g. `"InTime"`. Renames the `Northwind` token in all casings. |
|
| 20 |
+
| `slug` | derived, kebab/lower | output dir `<slug>-tse/`; also namespaces storage keys. |
|
| 21 |
+
| `host` | Data section | bare host, e.g. `team2.thoughtspot.cloud` (script adds `https://`). |
|
| 22 |
+
| `auth` | Data section | `none` \| `basic` \| `trusted`. `basic` wires `AuthType.Basic` + login creds. |
|
| 23 |
+
| `ids` | Data section | `analyticsLiveboard`, `model`, `inlineLiveboard`, `actionLiveboard`, `actionViz`. **Confirm each by TYPE** (liveboard vs viz) — a viz GUID in a liveboard slot = blank embed. |
|
| 24 |
+
| `action` | Custom-action section | `{ id, name }` — the context-menu action label. |
|
| 25 |
+
| `columns` | Data / inline / filters | `inlineName[]` (candidate list), `inlineMetrics[]` (`{label,format,candidates[]}`, ≤3), `filterPrimary[]`, `filterSecondary[]`, `date`, `hierarchy{segment,rep,cadence}`. Candidate lists = first that returns data wins. |
|
| 26 |
+
| `appFont` | — | **Ignored.** The embed font is now derived automatically from `font.family` (see below). Safe to omit. |
|
| 27 |
+
| `font` | Theme | the brand font. Two forms, both handled: **(1) Self-hosted** `{ family, file, srcPath }` — copied to `public/<file>` and `@font-face`'d in `globals.css` (host UI = real font); embeds get the nearest Google font (below). **(2) Google font by name** `{ family }` (optionally `{ googleUrl }`) — host loads it via `index.html`'s `<link>` (host UI = real font, no `@font-face`); embeds load it too (it's on a CDN → real font in embeds as well). Set `--font-serif`/`--font-sans` in `theme.light/dark` to `family`. **Embeds** always request `family` first, then the **nearest free Google font** as the reachable fallback (`FONT_FALLBACKS` in `apply-spec.mjs`: Larsseit→DM Sans, Gilroy→Poppins, Futura→Jost, Google faces→themselves, unknown→Inter); the real font renders in embeds only if it's a Google font or the cluster admin uploaded it. **Never self-host a font into the embeds** — it fails in the sandboxed iframe and blanks the theme. Omit `font` to keep Inter everywhere. |
|
| 28 |
+
| `logo` | Brand | `{ svgPath }` (repo-relative). Script rewrites `fill:#hex`→`currentColor` so it flips with theme. |
|
| 29 |
+
| `favicon` | Brand | inline SVG string → `public/<slug>-icon.svg`. |
|
| 30 |
+
| `theme` | Theme | see below. |
|
| 31 |
+
| `features` | multiple | see below — controls **tab pruning**. |
|
| 32 |
+
| `content` | all copy | the entire `content.ts` object (every human string). |
|
| 33 |
+
| `embedSwaps` / `greenSwaps` / `cssSwaps` | Theme | hex remaps — **fixed left side, brand right side** (see below). |
|
| 34 |
+
|
| 35 |
+
## `theme`
|
| 36 |
+
|
| 37 |
+
- `default`: `"light"` | `"dark"`.
|
| 38 |
+
- `light` / `dark`: the **full** `:root` token dictionaries (the codemod replaces the whole
|
| 39 |
+
block, so include every token — colors, fonts, `--topbar-h`, shadows). Derive the palette
|
| 40 |
+
from the brand screenshot/URL. Must include `--brand-navy` and `--brand-accent` (fixed
|
| 41 |
+
brand colors, **same value in light and dark** — they drive the chatbot, which is a light
|
| 42 |
+
card with a dark header in both themes).
|
| 43 |
+
|
| 44 |
+
## `features` (tab pruning the codemod performs)
|
| 45 |
+
|
| 46 |
+
- `askMode`: `"both"` (Ask + Spotter) · `"fancy"` (removes Spotter tab) · `"normal"` (removes Ask tab).
|
| 47 |
+
- `inline`: `false` removes the inline (Carriers/Shifts) tab + file.
|
| 48 |
+
- `action`: `false` removes the action (Capacity/Leaves) tab + BidModal.
|
| 49 |
+
- `monetize` / `tiers` / `pinning`: **automated** — the codemod writes `src/flags.ts` and the
|
| 50 |
+
template gates these in place (`{FLAGS.tiers && …}` etc.). `false` hides the paywall, the
|
| 51 |
+
tier switcher (tier stays Premium → everything unlocked), and the Add-Report panel. No hand-step.
|
| 52 |
+
|
| 53 |
+
## The swap arrays — fixed LHS, brand RHS
|
| 54 |
+
|
| 55 |
+
Each is `[from, to]`. The **left-hand values are constants** (the template's neutral
|
| 56 |
+
palette); change only the **right-hand** values to the brand equivalents. This is how the
|
| 57 |
+
embed CSS variables (indigo `config.ts`), the liveboard tab/button accents (green
|
| 58 |
+
`thoughtspot.ts`), and a few leftover dark-override rules (`globals.css`) get rebranded.
|
| 59 |
+
|
| 60 |
+
- **`embedSwaps`** (config.ts) LHS: `#4F5BD5` (primary), `#3F49B8` (hover), `#333C99` (active),
|
| 61 |
+
`#22B8CF` (gradient end), `#0b0d10` `#14181f` `#1a212b` `#232a34` (dark embed bg family),
|
| 62 |
+
`#0b1f19` (dark input), `rgba(45,164,191,0.14)` (ring).
|
| 63 |
+
- **`greenSwaps`** (thoughtspot.ts liveboard accents) LHS: `#0f9a63` `#0c8554` `#d9ecea`
|
| 64 |
+
`#cfe3e2` `#1bb978` `#15ae6e`.
|
| 65 |
+
- **`cssSwaps`** (globals.css dark-override rules) LHS: `#0e1116` `#10222a` `#123047`.
|
| 66 |
+
|
| 67 |
+
> The chatbot palette is **token-driven** (`--brand-navy`/`--brand-accent`), so it needs
|
| 68 |
+
> **no** swaps — just set those two theme tokens.
|
| 69 |
+
|
| 70 |
+
## After the codemod — hand-steps (what a script can't do)
|
| 71 |
+
|
| 72 |
+
1. **Custom-action modal** (if a workflow was described): **MANDATORY** — rebuild
|
| 73 |
+
`BidModal.tsx` to match the described screen (rename it, e.g. `LeaveRequestModal.tsx`),
|
| 74 |
+
per `build-playbook.md` §5. **Never ship the generic Request Bid form** for a described
|
| 75 |
+
workflow.
|
| 76 |
+
2. Any bespoke UI the user flagged (custom tables, live lookups).
|
| 77 |
+
|
| 78 |
+
Then: `npm run build` (fix TS errors), `npm run dev`, and print the spec recap + the
|
| 79 |
+
"verify on your cluster" list (resolved column names, CORS/CSP allowlist, auth requirement).
|
demoprep_app/portal/references/spec.example.json
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"client": "InTime",
|
| 3 |
+
"slug": "yourco",
|
| 4 |
+
"host": "YOUR-CLUSTER.thoughtspot.cloud",
|
| 5 |
+
"auth": "basic",
|
| 6 |
+
"ids": {
|
| 7 |
+
"analyticsLiveboard": "REPLACE_MAIN_LIVEBOARD_GUID",
|
| 8 |
+
"model": "REPLACE_MODEL_GUID",
|
| 9 |
+
"inlineLiveboard": "REPLACE_INLINE_LIVEBOARD_GUID",
|
| 10 |
+
"actionLiveboard": "REPLACE_MAIN_LIVEBOARD_GUID",
|
| 11 |
+
"actionViz": "REPLACE_ACTION_VIZ_GUID"
|
| 12 |
+
},
|
| 13 |
+
"action": {
|
| 14 |
+
"id": "request-leave",
|
| 15 |
+
"name": "Request Leave"
|
| 16 |
+
},
|
| 17 |
+
"columns": {
|
| 18 |
+
"inlineName": [
|
| 19 |
+
"Rank Name",
|
| 20 |
+
"Rank",
|
| 21 |
+
"Rank Type",
|
| 22 |
+
"Employee"
|
| 23 |
+
],
|
| 24 |
+
"inlineMetrics": [
|
| 25 |
+
{
|
| 26 |
+
"label": "Manual Overtime Hours",
|
| 27 |
+
"format": "number",
|
| 28 |
+
"candidates": [
|
| 29 |
+
"Total Manual Overtime Hours",
|
| 30 |
+
"Manual Overtime Hours",
|
| 31 |
+
"Overtime Hours"
|
| 32 |
+
]
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"label": "Overtime Cost",
|
| 36 |
+
"format": "currency",
|
| 37 |
+
"candidates": [
|
| 38 |
+
"Overtime Cost",
|
| 39 |
+
"Total Overtime Cost",
|
| 40 |
+
"OT Cost"
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
],
|
| 44 |
+
"filterPrimary": [
|
| 45 |
+
"Branch Name",
|
| 46 |
+
"Company Name",
|
| 47 |
+
"Branch",
|
| 48 |
+
"Dim Branch"
|
| 49 |
+
],
|
| 50 |
+
"filterSecondary": [
|
| 51 |
+
"Rank Name",
|
| 52 |
+
"Rank",
|
| 53 |
+
"Rank Type"
|
| 54 |
+
],
|
| 55 |
+
"date": "Sched Start Time",
|
| 56 |
+
"hierarchy": {
|
| 57 |
+
"segment": "Branch Name",
|
| 58 |
+
"rep": "Rank Name",
|
| 59 |
+
"cadence": "Employee"
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"appFont": "'Larsseit', 'Inter', 'Segoe UI', sans-serif",
|
| 63 |
+
"font": {
|
| 64 |
+
"family": "Manrope",
|
| 65 |
+
"googleUrl": "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
|
| 66 |
+
},
|
| 67 |
+
"logo": {
|
| 68 |
+
"svgPath": "your-logo.svg"
|
| 69 |
+
},
|
| 70 |
+
"favicon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\" width=\"32\" height=\"32\"><rect width=\"32\" height=\"32\" rx=\"7\" fill=\"#0E3147\"/><circle cx=\"16\" cy=\"9\" r=\"2.4\" fill=\"#F4D14E\"/><rect x=\"13.7\" y=\"13\" width=\"4.6\" height=\"12.5\" rx=\"2.3\" fill=\"#ffffff\"/></svg>",
|
| 71 |
+
"theme": {
|
| 72 |
+
"default": "light",
|
| 73 |
+
"light": {
|
| 74 |
+
"--sl-cream": "#f4f7fb",
|
| 75 |
+
"--sl-cream-2": "#e9f0f7",
|
| 76 |
+
"--sl-white": "#ffffff",
|
| 77 |
+
"--sl-evergreen": "#0e3147",
|
| 78 |
+
"--sl-evergreen-2": "#0a2436",
|
| 79 |
+
"--sl-green": "#0e3147",
|
| 80 |
+
"--sl-green-dark": "#16466a",
|
| 81 |
+
"--sl-primary-rgb": "14, 49, 71",
|
| 82 |
+
"--intime-blue": "#2f6fb0",
|
| 83 |
+
"--sl-gold": "#f4d14e",
|
| 84 |
+
"--sl-gold-ink": "#6b5410",
|
| 85 |
+
"--intime-grad": "linear-gradient(90deg, #0e3147 0%, #2f6fb0 100%)",
|
| 86 |
+
"--sl-mint": "#e6f1fb",
|
| 87 |
+
"--sl-mint-2": "#d5e8f8",
|
| 88 |
+
"--sl-coral": "#a2191f",
|
| 89 |
+
"--sl-warn": "#fffbe6",
|
| 90 |
+
"--sl-warn-ink": "#6b5410",
|
| 91 |
+
"--sl-ink": "#12283f",
|
| 92 |
+
"--sl-muted": "#5a6a7e",
|
| 93 |
+
"--sl-border": "#e1e8f0",
|
| 94 |
+
"--sl-border-soft": "#eef2f7",
|
| 95 |
+
"--font-serif": "'Larsseit', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 96 |
+
"--font-sans": "'Larsseit', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 97 |
+
"--topbar-h": "66px",
|
| 98 |
+
"--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)",
|
| 99 |
+
"--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)",
|
| 100 |
+
"--brand-navy": "#0e3147",
|
| 101 |
+
"--brand-accent": "#2f6fb0"
|
| 102 |
+
},
|
| 103 |
+
"dark": {
|
| 104 |
+
"--sl-cream": "#0a2233",
|
| 105 |
+
"--sl-cream-2": "#0e293c",
|
| 106 |
+
"--sl-white": "#12314a",
|
| 107 |
+
"--sl-evergreen": "#eef4fa",
|
| 108 |
+
"--sl-evergreen-2": "#dbe6f0",
|
| 109 |
+
"--sl-green": "#7fb0e0",
|
| 110 |
+
"--sl-green-dark": "#5e97cc",
|
| 111 |
+
"--sl-primary-rgb": "127, 176, 224",
|
| 112 |
+
"--intime-blue": "#7fb0e0",
|
| 113 |
+
"--sl-gold": "#f4d14e",
|
| 114 |
+
"--sl-gold-ink": "#0a2233",
|
| 115 |
+
"--intime-grad": "linear-gradient(90deg, #7fb0e0 0%, #bfe0f5 100%)",
|
| 116 |
+
"--sl-mint": "#10293d",
|
| 117 |
+
"--sl-mint-2": "#16344c",
|
| 118 |
+
"--sl-coral": "#f0616a",
|
| 119 |
+
"--sl-ink": "#eaf1f7",
|
| 120 |
+
"--sl-muted": "#9fb2c6",
|
| 121 |
+
"--sl-border": "#23415a",
|
| 122 |
+
"--sl-border-soft": "#172f44",
|
| 123 |
+
"--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)",
|
| 124 |
+
"--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)",
|
| 125 |
+
"--brand-navy": "#0e3147",
|
| 126 |
+
"--brand-accent": "#2f6fb0"
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
"embedSwaps": [
|
| 130 |
+
[
|
| 131 |
+
"#4F5BD5",
|
| 132 |
+
"#2f6fb0"
|
| 133 |
+
],
|
| 134 |
+
[
|
| 135 |
+
"#3F49B8",
|
| 136 |
+
"#285f97"
|
| 137 |
+
],
|
| 138 |
+
[
|
| 139 |
+
"#333C99",
|
| 140 |
+
"#22527f"
|
| 141 |
+
],
|
| 142 |
+
[
|
| 143 |
+
"#22B8CF",
|
| 144 |
+
"#7fb0e0"
|
| 145 |
+
],
|
| 146 |
+
[
|
| 147 |
+
"#0b0d10",
|
| 148 |
+
"#0a2233"
|
| 149 |
+
],
|
| 150 |
+
[
|
| 151 |
+
"#14181f",
|
| 152 |
+
"#12314a"
|
| 153 |
+
],
|
| 154 |
+
[
|
| 155 |
+
"#1a212b",
|
| 156 |
+
"#16344c"
|
| 157 |
+
],
|
| 158 |
+
[
|
| 159 |
+
"#232a34",
|
| 160 |
+
"#23415a"
|
| 161 |
+
],
|
| 162 |
+
[
|
| 163 |
+
"#0b1f19",
|
| 164 |
+
"#0e293c"
|
| 165 |
+
],
|
| 166 |
+
[
|
| 167 |
+
"rgba(45,164,191,0.14)",
|
| 168 |
+
"rgba(127,176,224,0.16)"
|
| 169 |
+
]
|
| 170 |
+
],
|
| 171 |
+
"greenSwaps": [
|
| 172 |
+
[
|
| 173 |
+
"#0f9a63",
|
| 174 |
+
"#16344c"
|
| 175 |
+
],
|
| 176 |
+
[
|
| 177 |
+
"#0c8554",
|
| 178 |
+
"#12314a"
|
| 179 |
+
],
|
| 180 |
+
[
|
| 181 |
+
"#d9ecea",
|
| 182 |
+
"#d5e8f8"
|
| 183 |
+
],
|
| 184 |
+
[
|
| 185 |
+
"#cfe3e2",
|
| 186 |
+
"#c2ddf4"
|
| 187 |
+
],
|
| 188 |
+
[
|
| 189 |
+
"#1bb978",
|
| 190 |
+
"#7fb0e0"
|
| 191 |
+
],
|
| 192 |
+
[
|
| 193 |
+
"#15ae6e",
|
| 194 |
+
"#0e3147"
|
| 195 |
+
]
|
| 196 |
+
],
|
| 197 |
+
"cssSwaps": [
|
| 198 |
+
[
|
| 199 |
+
"#0e1116",
|
| 200 |
+
"#0e293c"
|
| 201 |
+
],
|
| 202 |
+
[
|
| 203 |
+
"#10222a",
|
| 204 |
+
"#0e293c"
|
| 205 |
+
],
|
| 206 |
+
[
|
| 207 |
+
"#123047",
|
| 208 |
+
"#12314a"
|
| 209 |
+
]
|
| 210 |
+
],
|
| 211 |
+
"features": {
|
| 212 |
+
"askMode": "both",
|
| 213 |
+
"inline": true,
|
| 214 |
+
"action": true,
|
| 215 |
+
"monetize": true,
|
| 216 |
+
"tiers": true,
|
| 217 |
+
"pinning": true
|
| 218 |
+
},
|
| 219 |
+
"content": {
|
| 220 |
+
"company": "InTime",
|
| 221 |
+
"aiName": "InTime AI",
|
| 222 |
+
"website": "https://www.intime.com",
|
| 223 |
+
"loginTitleHtml": "Scheduling built for<br />public safety.",
|
| 224 |
+
"loginSubtitle": "Your command center for police, fire, corrections and dispatch \u2014 schedules, overtime and coverage, with AI-powered answers on live data. Built on InTime, powered by ThoughtSpot.",
|
| 225 |
+
"loginStats": [
|
| 226 |
+
{
|
| 227 |
+
"value": "24/7",
|
| 228 |
+
"label": "Coverage Managed"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"value": "98%",
|
| 232 |
+
"label": "Shifts Filled"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"value": "Real-time",
|
| 236 |
+
"label": "Overtime Intel"
|
| 237 |
+
}
|
| 238 |
+
],
|
| 239 |
+
"loginCardSubtitle": "Sign in with your ThoughtSpot credentials",
|
| 240 |
+
"loginDemoNoteHtml": "<strong>Sign in</strong> with your ThoughtSpot username & password for <code>{host}</code>. This origin must be CORS-allowlisted on that cluster for the embeds to load.",
|
| 241 |
+
"platformNav": [
|
| 242 |
+
"Police",
|
| 243 |
+
"Fire",
|
| 244 |
+
"Corrections"
|
| 245 |
+
],
|
| 246 |
+
"tabs": {
|
| 247 |
+
"myReports": "My Reports",
|
| 248 |
+
"analytics": "Analytics",
|
| 249 |
+
"inline": "Shifts",
|
| 250 |
+
"action": "Leaves",
|
| 251 |
+
"ask": "Ask InTime AI",
|
| 252 |
+
"spotter": "Spotter"
|
| 253 |
+
},
|
| 254 |
+
"analyticsSubtitle": "Your live scheduling, overtime & coverage dashboard",
|
| 255 |
+
"spotterSubtitle": "Ask questions of your InTime scheduling & overtime data in natural language.",
|
| 256 |
+
"inline": {
|
| 257 |
+
"subtitleReady": "Overtime & hours by rank \u2014 select a rank to view its shift insights",
|
| 258 |
+
"subtitleIdle": "Overtime and hours across your ranks",
|
| 259 |
+
"loading": "Loading ranks\u2026",
|
| 260 |
+
"errorTitle": "Couldn't load shifts",
|
| 261 |
+
"errorMsg": "Unable to load shift data.",
|
| 262 |
+
"moreLabel": "View insights",
|
| 263 |
+
"hideLabel": "Hide insights"
|
| 264 |
+
},
|
| 265 |
+
"filters": {
|
| 266 |
+
"primaryLabel": "Branch",
|
| 267 |
+
"secondaryLabel": "Rank"
|
| 268 |
+
},
|
| 269 |
+
"sampleQuestions": [
|
| 270 |
+
"Overtime hours by rank",
|
| 271 |
+
"Overtime cost by branch",
|
| 272 |
+
"Worked hours by employee",
|
| 273 |
+
"Leave hours over time"
|
| 274 |
+
],
|
| 275 |
+
"askWelcome": "Ask me anything about InTime \u2014 scheduling, overtime, leave, coverage, best practices \u2014 or ask about your own data and I will chart it live.",
|
| 276 |
+
"askEmptySub": "Ask any analytical question about your schedules, overtime, leave, and coverage. Answers come straight from your live data with an interactive chart you can drill into.",
|
| 277 |
+
"action": {
|
| 278 |
+
"label": "Request Leave",
|
| 279 |
+
"subtitlePrefix": "Officer schedules and coverage. Right-click a row and choose",
|
| 280 |
+
"subtitleSuffix": "to open a leave request pre-filled for that officer.",
|
| 281 |
+
"modalTitle": "New Leave Request",
|
| 282 |
+
"modalLead": "Pre-filled for the selected officer. Review and submit to request time off through InTime.",
|
| 283 |
+
"submitLabel": "Submit",
|
| 284 |
+
"successTitle": "Leave request submitted"
|
| 285 |
+
},
|
| 286 |
+
"chatbot": {
|
| 287 |
+
"welcome": "Hi! I'm InTime AI. Ask me about scheduling, overtime and coverage, or ask a question about your data.",
|
| 288 |
+
"overview": "InTime is workforce scheduling and time & attendance software built for public safety \u2014 police, sheriff, fire, corrections, and dispatch. It manages shift schedules, overtime, leave, court time, and certifications, with AI-powered answers on live workforce data.",
|
| 289 |
+
"greetingExample": "overtime hours by rank",
|
| 290 |
+
"examples": [
|
| 291 |
+
"worked hours by week",
|
| 292 |
+
"overtime cost by branch"
|
| 293 |
+
]
|
| 294 |
+
},
|
| 295 |
+
"trialQuestionTrigger": 3,
|
| 296 |
+
"upgradeReason": "Upgrade to InTime Premium to unlock drill-down, Ask AI and downloads.",
|
| 297 |
+
"analyticsKeywords": [
|
| 298 |
+
"overtime",
|
| 299 |
+
"worked",
|
| 300 |
+
"leave",
|
| 301 |
+
"coverage",
|
| 302 |
+
"shift",
|
| 303 |
+
"shifts",
|
| 304 |
+
"headcount",
|
| 305 |
+
"staffing",
|
| 306 |
+
"rank",
|
| 307 |
+
"branch",
|
| 308 |
+
"officer",
|
| 309 |
+
"hours"
|
| 310 |
+
]
|
| 311 |
+
}
|
| 312 |
+
}
|
demoprep_app/portal/scripts/apply-spec.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// ---------------------------------------------------------------------------
|
| 3 |
+
// apply-spec.mjs — spec.json -> a fully-skinned <slug>-tse/ app.
|
| 4 |
+
//
|
| 5 |
+
// node scripts/apply-spec.mjs <spec.json>
|
| 6 |
+
//
|
| 7 |
+
// Deterministic codemod for the mechanizable ~90% of a reskin: clone the
|
| 8 |
+
// template, rename the brand token, write content.ts (all copy), patch config.ts
|
| 9 |
+
// (host / GUIDs / columns / action / embed theme), patch auth + fonts, regenerate
|
| 10 |
+
// the globals.css theme tokens, drop in the logo/favicon/font, and prune features
|
| 11 |
+
// the spec turns off. Bespoke workflow modals (e.g. a custom leave form) are NOT
|
| 12 |
+
// generated — they stay a manual step, and the script prints a reminder.
|
| 13 |
+
//
|
| 14 |
+
// Run from your project root. The skill is self-contained: the base template is
|
| 15 |
+
// bundled next to this script (../template-tse), and the generated <slug>-tse/ app
|
| 16 |
+
// is written into the current working directory. Paths in the spec (logo.svgPath,
|
| 17 |
+
// font.srcPath) are resolved relative to the current working directory.
|
| 18 |
+
// ---------------------------------------------------------------------------
|
| 19 |
+
import fs from 'node:fs';
|
| 20 |
+
import path from 'node:path';
|
| 21 |
+
import { fileURLToPath } from 'node:url';
|
| 22 |
+
import { execSync } from 'node:child_process';
|
| 23 |
+
|
| 24 |
+
const ROOT = process.cwd(); // where the app is written
|
| 25 |
+
const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
| 26 |
+
const TEMPLATE = path.join(SKILL_DIR, 'template-tse'); // bundled with the skill
|
| 27 |
+
const SKIP = new Set(['node_modules', 'dist', '.vercel', '.git']);
|
| 28 |
+
|
| 29 |
+
const specPath = process.argv[2];
|
| 30 |
+
if (!specPath) { console.error('usage: node scripts/apply-spec.mjs <spec.json>'); process.exit(1); }
|
| 31 |
+
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
| 32 |
+
const t0 = Date.now();
|
| 33 |
+
|
| 34 |
+
const slug = spec.slug;
|
| 35 |
+
const client = spec.client; // Title-case, e.g. "InTime"
|
| 36 |
+
const CLIENT = client.toUpperCase();
|
| 37 |
+
const clientLower = slug; // lowercase token
|
| 38 |
+
const OUT = path.join(ROOT, `${slug}-tse`);
|
| 39 |
+
|
| 40 |
+
// Embed font: the brand font can't be pushed into ThoughtSpot's sandboxed iframe
|
| 41 |
+
// unless it's on a CDN or uploaded to the cluster. So we request the brand font
|
| 42 |
+
// first (renders if the cluster has it) and load the NEAREST Google font as the
|
| 43 |
+
// reachable fallback. Map common brand faces -> closest free Google font.
|
| 44 |
+
const gfontUrl = (fam) =>
|
| 45 |
+
`https://fonts.googleapis.com/css2?family=${fam.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`;
|
| 46 |
+
const FONT_FALLBACKS = {
|
| 47 |
+
larsseit: 'DM Sans', circular: 'Mulish', gilroy: 'Poppins', gotham: 'Montserrat',
|
| 48 |
+
avenir: 'Nunito Sans', 'avenir next': 'Nunito Sans', 'proxima nova': 'Montserrat',
|
| 49 |
+
metric: 'Inter', graphik: 'Public Sans', futura: 'Jost', 'brandon grotesque': 'Montserrat',
|
| 50 |
+
helvetica: 'Inter', arial: 'Inter',
|
| 51 |
+
// Google faces map to themselves (so an explicit Google font just works)
|
| 52 |
+
inter: 'Inter', roboto: 'Roboto', 'open sans': 'Open Sans', lato: 'Lato',
|
| 53 |
+
montserrat: 'Montserrat', poppins: 'Poppins', 'dm sans': 'DM Sans', mulish: 'Mulish',
|
| 54 |
+
nunito: 'Nunito', 'nunito sans': 'Nunito Sans', jost: 'Jost', manrope: 'Manrope',
|
| 55 |
+
};
|
| 56 |
+
const brandFamily = (spec.font && spec.font.family) || 'Inter';
|
| 57 |
+
const fbFamily = FONT_FALLBACKS[brandFamily.toLowerCase()] || 'Inter';
|
| 58 |
+
const embedFamily = `'${brandFamily}', '${fbFamily}', system-ui, -apple-system, 'Segoe UI', sans-serif`;
|
| 59 |
+
const embedFontUrl = (spec.font && spec.font.googleUrl) || gfontUrl(fbFamily);
|
| 60 |
+
// Host-UI font: a self-hosted file is @font-face'd; a Google font (given by name
|
| 61 |
+
// or googleUrl) is loaded via index.html's <link>. isBrandGoogleFont is true when
|
| 62 |
+
// the brand font IS itself a Google font (map returns it unchanged).
|
| 63 |
+
const isBrandGoogleFont = fbFamily.toLowerCase() === brandFamily.toLowerCase() && brandFamily.toLowerCase() !== 'inter';
|
| 64 |
+
const hostGoogleUrl = spec.font
|
| 65 |
+
? (spec.font.googleUrl || (isBrandGoogleFont && !spec.font.file ? gfontUrl(brandFamily) : null))
|
| 66 |
+
: null;
|
| 67 |
+
|
| 68 |
+
// ---- helpers ---------------------------------------------------------------
|
| 69 |
+
function copyDir(src, dst) {
|
| 70 |
+
fs.mkdirSync(dst, { recursive: true });
|
| 71 |
+
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
|
| 72 |
+
if (SKIP.has(ent.name)) continue;
|
| 73 |
+
const s = path.join(src, ent.name), d = path.join(dst, ent.name);
|
| 74 |
+
if (ent.isDirectory()) copyDir(s, d);
|
| 75 |
+
else fs.copyFileSync(s, d);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
function walk(dir, fn) {
|
| 79 |
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
| 80 |
+
if (SKIP.has(ent.name)) continue;
|
| 81 |
+
const p = path.join(dir, ent.name);
|
| 82 |
+
if (ent.isDirectory()) walk(p, fn);
|
| 83 |
+
else fn(p);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
const read = (p) => fs.readFileSync(p, 'utf8');
|
| 87 |
+
const write = (p, s) => fs.writeFileSync(p, s);
|
| 88 |
+
function edit(p, fn) { write(p, fn(read(p))); }
|
| 89 |
+
function renameBrand(s) {
|
| 90 |
+
return s.split('Northwind').join(client)
|
| 91 |
+
.split('NORTHWIND').join(CLIENT)
|
| 92 |
+
.split('northwind').join(clientLower);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---- 0. clone --------------------------------------------------------------
|
| 96 |
+
// Zero-setup: the generated apps share the bundled template's node_modules. If it
|
| 97 |
+
// hasn't been installed yet (fresh clone of the skill), install it now — once —
|
| 98 |
+
// instead of failing the first build with a confusing missing-deps error.
|
| 99 |
+
ensureTemplateDeps();
|
| 100 |
+
|
| 101 |
+
if (fs.existsSync(OUT)) fs.rmSync(OUT, { recursive: true, force: true });
|
| 102 |
+
copyDir(TEMPLATE, OUT);
|
| 103 |
+
// symlink deps to the bundled template's node_modules. Absolute target so it works
|
| 104 |
+
// wherever the generated app is created.
|
| 105 |
+
try { fs.symlinkSync(path.join(TEMPLATE, 'node_modules'), path.join(OUT, 'node_modules')); } catch {}
|
| 106 |
+
|
| 107 |
+
function ensureTemplateDeps() {
|
| 108 |
+
// A real install has node_modules AND the SDK dep resolved (a bare/partial dir
|
| 109 |
+
// would still fail the build). Check for the SDK to catch a half-populated dir.
|
| 110 |
+
const nm = path.join(TEMPLATE, 'node_modules');
|
| 111 |
+
const sdk = path.join(nm, '@thoughtspot', 'visual-embed-sdk');
|
| 112 |
+
if (fs.existsSync(sdk)) return;
|
| 113 |
+
// node/npm often aren't on the default PATH (they live at ~/.node/bin here) —
|
| 114 |
+
// prepend it so `npm` resolves whether or not the caller exported it.
|
| 115 |
+
const home = process.env.HOME || '';
|
| 116 |
+
const PATH = `${path.join(home, '.node', 'bin')}:${process.env.PATH || ''}`;
|
| 117 |
+
console.log('[apply-spec] template deps not installed — running `npm install` once (this can take a minute)…');
|
| 118 |
+
try {
|
| 119 |
+
execSync('npm install --no-audit --no-fund', {
|
| 120 |
+
cwd: TEMPLATE,
|
| 121 |
+
stdio: 'inherit',
|
| 122 |
+
env: { ...process.env, PATH },
|
| 123 |
+
});
|
| 124 |
+
} catch {
|
| 125 |
+
console.error(
|
| 126 |
+
`[apply-spec] auto-install failed. Install manually, then re-run:\n` +
|
| 127 |
+
` (cd "${TEMPLATE}" && npm install)`,
|
| 128 |
+
);
|
| 129 |
+
process.exit(1);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// ---- 1. brand rename (all casings) + file renames --------------------------
|
| 134 |
+
walk(path.join(OUT, 'src'), (p) => {
|
| 135 |
+
if (/\.(ts|tsx|css)$/.test(p)) edit(p, renameBrand);
|
| 136 |
+
});
|
| 137 |
+
edit(path.join(OUT, 'index.html'), renameBrand);
|
| 138 |
+
// Host webfont: point index.html's font <link> at the brand Google font when the
|
| 139 |
+
// host font is a Google font (self-hosted fonts are handled by @font-face above).
|
| 140 |
+
if (hostGoogleUrl) {
|
| 141 |
+
edit(path.join(OUT, 'index.html'), (s) =>
|
| 142 |
+
s.replace('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', hostGoogleUrl));
|
| 143 |
+
}
|
| 144 |
+
edit(path.join(OUT, 'package.json'), (s) => s.replace(/northwind-thoughtspot-portal/g, `${slug}-thoughtspot-portal`));
|
| 145 |
+
const mv = (a, b) => { const A = path.join(OUT, a), B = path.join(OUT, b); if (fs.existsSync(A)) fs.renameSync(A, B); };
|
| 146 |
+
mv('src/components/NorthwindLogo.tsx', `src/components/${client}Logo.tsx`);
|
| 147 |
+
mv('src/tabs/AskNorthwind.tsx', `src/tabs/Ask${client}.tsx`);
|
| 148 |
+
|
| 149 |
+
// ---- 2. content.ts (all copy, from spec) -----------------------------------
|
| 150 |
+
const contentFile =
|
| 151 |
+
`// Generated by apply-spec.mjs — single source of truth for all portal copy.
|
| 152 |
+
export interface LoginStat { value: string; label: string; }
|
| 153 |
+
export const CONTENT = ${JSON.stringify(spec.content, null, 2)} as const;
|
| 154 |
+
export type Content = typeof CONTENT;
|
| 155 |
+
`;
|
| 156 |
+
write(path.join(OUT, 'src/content.ts'), contentFile);
|
| 157 |
+
|
| 158 |
+
// ---- 2b. flags.ts — feature toggles (monetize / tiers / pinning) -----------
|
| 159 |
+
// Tabs (inline/action/askMode) are PRUNED below; these three are gated in-place
|
| 160 |
+
// via FLAGS so shared components (paywall, tier switcher, Add-Report) render only
|
| 161 |
+
// when on. Default true when the spec omits a flag.
|
| 162 |
+
{
|
| 163 |
+
const ff = (spec.features || {});
|
| 164 |
+
const b = (v) => (v === false ? 'false' : 'true');
|
| 165 |
+
write(path.join(OUT, 'src/flags.ts'),
|
| 166 |
+
'// Generated by apply-spec.mjs from spec.features.\n' +
|
| 167 |
+
'export const FLAGS = {\n' +
|
| 168 |
+
` monetize: ${b(ff.monetize)},\n` +
|
| 169 |
+
` tiers: ${b(ff.tiers)},\n` +
|
| 170 |
+
` pinning: ${b(ff.pinning)},\n` +
|
| 171 |
+
'} as const;\n');
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ---- 3. config.ts — IDs / host / action / columns / embed theme ------------
|
| 175 |
+
edit(path.join(OUT, 'src/config.ts'), (s) => {
|
| 176 |
+
const i = spec.ids;
|
| 177 |
+
s = s.replace('https://YOUR-CLUSTER.thoughtspot.cloud', `https://${spec.host}`)
|
| 178 |
+
.replace('REPLACE_MAIN_LIVEBOARD_GUID', i.analyticsLiveboard)
|
| 179 |
+
.replace('REPLACE_INLINE_LIVEBOARD_GUID', i.inlineLiveboard)
|
| 180 |
+
.replace(/REPLACE_MODEL_GUID/g, i.model)
|
| 181 |
+
.replace('REPLACE_ACTION_LIVEBOARD_GUID', i.actionLiveboard)
|
| 182 |
+
.replace('REPLACE_ACTION_VIZ_GUID', i.actionViz)
|
| 183 |
+
.replace("'request-bid'", `'${spec.action.id}'`)
|
| 184 |
+
.replace("'Request Bid'", `'${spec.action.name}'`);
|
| 185 |
+
// candidate column arrays
|
| 186 |
+
const arr = (name, items) =>
|
| 187 |
+
s = s.replace(new RegExp(`export const ${name} = \\[[\\s\\S]*?\\];`),
|
| 188 |
+
`export const ${name} = [\n${items.map((x) => ` '${x}',`).join('\n')}\n];`);
|
| 189 |
+
arr('CADENCE_COLUMN_CANDIDATES', spec.columns.inlineName);
|
| 190 |
+
arr('REGION_COLUMN_CANDIDATES', spec.columns.filterPrimary);
|
| 191 |
+
arr('CORRIDOR_COLUMN_CANDIDATES', spec.columns.filterSecondary);
|
| 192 |
+
// inline detail metrics (CADENCE_DETAIL_COLUMNS)
|
| 193 |
+
const keys = spec.columns.inlineMetrics.map((_, n) => `metric${n + 1}`);
|
| 194 |
+
const body = spec.columns.inlineMetrics.map((m, n) =>
|
| 195 |
+
` ${keys[n]}: {\n label: '${m.label}',\n format: '${m.format}',\n candidates: [${m.candidates.map((c) => `'${c}'`).join(', ')}],\n },`).join('\n');
|
| 196 |
+
s = s.replace(/export const CADENCE_DETAIL_COLUMNS: Record<[\s\S]*?\n> = \{[\s\S]*?\n\};/,
|
| 197 |
+
`export const CADENCE_DETAIL_COLUMNS: Record<\n ${keys.map((k) => `'${k}'`).join(' | ')},\n { label: string; format: 'text' | 'date' | 'number' | 'currency'; candidates: string[] }\n> = {\n${body}\n};`);
|
| 198 |
+
// hierarchy + date filter columns
|
| 199 |
+
s = s.replace(/export const SEGMENT_COLUMN = '[^']*';/, `export const SEGMENT_COLUMN = '${spec.columns.hierarchy.segment}';`)
|
| 200 |
+
.replace(/export const REP_COLUMN = '[^']*';/, `export const REP_COLUMN = '${spec.columns.hierarchy.rep}';`)
|
| 201 |
+
.replace(/export const CADENCE_NAME_COLUMN = '[^']*';/, `export const CADENCE_NAME_COLUMN = '${spec.columns.hierarchy.cadence}';`)
|
| 202 |
+
.replace(/export const DATE_COLUMN = '[^']*';/, `export const DATE_COLUMN = '${spec.columns.date}';`);
|
| 203 |
+
// embed theme hex swaps (indigo -> brand)
|
| 204 |
+
for (const [a, b] of spec.embedSwaps || []) s = s.split(a).join(b);
|
| 205 |
+
// Brand-tint the LIGHT embed surfaces (secondary buttons, chips, hovers, tile
|
| 206 |
+
// borders, action buttons, menus) by blending the primary toward white — so
|
| 207 |
+
// surfaces follow the brand instead of a flat grey. The neutral hexes below are
|
| 208 |
+
// the template's surface placeholders acting as swap keys.
|
| 209 |
+
{
|
| 210 |
+
const h2 = (h) => { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; };
|
| 211 |
+
const x = (n) => Math.round(n).toString(16).padStart(2, '0');
|
| 212 |
+
const mixW = (hex, t) => { const [r, g, b] = h2(hex); return '#' + x(r + (255 - r) * t) + x(g + (255 - g) * t) + x(b + (255 - b) * t); };
|
| 213 |
+
const prim = (spec.theme && spec.theme.light && spec.theme.light['--sl-green']) || '#4F5BD5';
|
| 214 |
+
const surf = {
|
| 215 |
+
'#FCFCFD': mixW(prim, 0.975), '#F5F6F8': mixW(prim, 0.96), '#F5F6F9': mixW(prim, 0.96),
|
| 216 |
+
'#EEF0F4': mixW(prim, 0.92), '#EEF0F5': mixW(prim, 0.92), '#F1F2F6': mixW(prim, 0.945),
|
| 217 |
+
'#E3E6EC': mixW(prim, 0.86), '#E8EAF1': mixW(prim, 0.90), '#E2E4EC': mixW(prim, 0.88),
|
| 218 |
+
'#D7DBE3': mixW(prim, 0.80),
|
| 219 |
+
};
|
| 220 |
+
for (const [g, v] of Object.entries(surf)) s = s.split(g).join(v);
|
| 221 |
+
}
|
| 222 |
+
// embed font: brand font first, nearest reachable Google font as fallback
|
| 223 |
+
s = s.replace(/export const EMBED_FONT_FAMILY = '[^']*';/,
|
| 224 |
+
`export const EMBED_FONT_FAMILY = ${JSON.stringify(embedFamily)};`);
|
| 225 |
+
// the stylesheet actually loaded into the iframe — always a reliable Google URL
|
| 226 |
+
s = s.replace(/export const TS_FONT_URL =\n '[^']*';/,
|
| 227 |
+
`export const TS_FONT_URL =\n ${JSON.stringify(embedFontUrl)};`);
|
| 228 |
+
return s;
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
// ---- 4. thoughtspot.ts — auth + embed font + accent swaps ------------------
|
| 232 |
+
edit(path.join(OUT, 'src/lib/thoughtspot.ts'), (s) => {
|
| 233 |
+
if (spec.auth === 'basic') {
|
| 234 |
+
s = s.replace(/void username;\n\s*void password;\n/, '')
|
| 235 |
+
.replace('authType: AuthType.None,', 'authType: AuthType.Basic,\n username,\n password,');
|
| 236 |
+
}
|
| 237 |
+
s = s.replace(/const APP_FONT = "[^"]*";/, `const APP_FONT = ${JSON.stringify(embedFamily)};`);
|
| 238 |
+
for (const [a, b] of spec.embedSwaps || []) s = s.split(a).join(b);
|
| 239 |
+
for (const [a, b] of spec.greenSwaps || []) s = s.split(a).join(b);
|
| 240 |
+
return s;
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
// ---- 5. globals.css — regenerate theme tokens + @font-face -----------------
|
| 244 |
+
edit(path.join(OUT, 'src/styles/globals.css'), (s) => {
|
| 245 |
+
// Guarantee the fixed brand tokens the chatbot depends on — if the spec's theme
|
| 246 |
+
// dicts omit them, the chat FAB/header background (var(--brand-accent/navy)) would
|
| 247 |
+
// be undefined and render transparent. Default from the light primary + ink.
|
| 248 |
+
for (const key of ['light', 'dark']) {
|
| 249 |
+
const d = spec.theme[key] || {};
|
| 250 |
+
if (!d['--brand-accent']) d['--brand-accent'] = spec.theme.light['--sl-green'] || '#4F5BD5';
|
| 251 |
+
if (!d['--brand-navy']) d['--brand-navy'] = spec.theme.light['--sl-evergreen'] || '#12203a';
|
| 252 |
+
spec.theme[key] = d;
|
| 253 |
+
}
|
| 254 |
+
const block = (dict) => Object.entries(dict).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
| 255 |
+
s = s.replace(/:root \{\n[\s\S]*?\n\}/, `:root {\n${block(spec.theme.light)}\n}`);
|
| 256 |
+
s = s.replace(/:root\[data-theme='dark'\] \{\n[\s\S]*?\n\}/, `:root[data-theme='dark'] {\n${block(spec.theme.dark)}\n}`);
|
| 257 |
+
// leftover dark-override rules (login-left gradient, branded header fills)
|
| 258 |
+
for (const [a, b] of spec.cssSwaps || []) s = s.split(a).join(b);
|
| 259 |
+
if (spec.font && spec.font.file) {
|
| 260 |
+
const face =
|
| 261 |
+
`@font-face {\n font-family: '${spec.font.family}';\n font-style: normal;\n font-weight: normal;\n font-display: swap;\n src: url('/${spec.font.file}') format('woff2');\n}\n\n`;
|
| 262 |
+
s = face + s;
|
| 263 |
+
}
|
| 264 |
+
return s;
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
// ---- 6. theme default + assets --------------------------------------------
|
| 268 |
+
edit(path.join(OUT, 'src/context/ThemeContext.tsx'), (s) =>
|
| 269 |
+
s.replace(/: 'dark';/, `: '${spec.theme.default}';`).replace(/: 'light';/, `: '${spec.theme.default}';`));
|
| 270 |
+
if (spec.logo?.svgPath) {
|
| 271 |
+
const svg = read(path.join(ROOT, spec.logo.svgPath)).replace(/fill:\s*#[0-9A-Fa-f]{3,6}/g, 'fill:currentColor');
|
| 272 |
+
write(path.join(OUT, `src/assets/${slug}-logo.svg`), svg);
|
| 273 |
+
}
|
| 274 |
+
if (spec.favicon) write(path.join(OUT, `public/${slug}-icon.svg`), spec.favicon);
|
| 275 |
+
if (spec.font?.srcPath) fs.copyFileSync(path.join(ROOT, spec.font.srcPath), path.join(OUT, `public/${spec.font.file}`));
|
| 276 |
+
// remove stale template assets
|
| 277 |
+
for (const f of ['public/northwind-icon.svg', 'src/assets/northwind-logo.svg', 'public/figma-reference.png'])
|
| 278 |
+
{ const p = path.join(OUT, f); if (fs.existsSync(p)) fs.rmSync(p); }
|
| 279 |
+
|
| 280 |
+
// ---- 7. prune features the spec turns off ----------------------------------
|
| 281 |
+
const f = spec.features || {};
|
| 282 |
+
const rmTab = (id, extraFiles = []) => {
|
| 283 |
+
edit(path.join(OUT, 'src/App.tsx'), (s) => s.replace(new RegExp(`\\s*\\{tab === '${id}' && <[^>]+>\\}`), ''));
|
| 284 |
+
edit(path.join(OUT, 'src/components/TopBar.tsx'), (s) => s.replace(new RegExp(`\\s*\\{ id: '${id}',[^\\n]*\\},`), ''));
|
| 285 |
+
for (const rel of extraFiles) { const p = path.join(OUT, rel); if (fs.existsSync(p)) fs.rmSync(p); }
|
| 286 |
+
};
|
| 287 |
+
if (f.inline === false) rmTab('carriers', ['src/tabs/Carriers.tsx']);
|
| 288 |
+
if (f.action === false) rmTab('capacity', ['src/tabs/Capacity.tsx', 'src/components/BidModal.tsx']);
|
| 289 |
+
if (f.askMode === 'fancy') rmTab('spotter', ['src/tabs/Spotter.tsx']);
|
| 290 |
+
if (f.askMode === 'normal') rmTab('ask', [`src/tabs/Ask${client}.tsx`]);
|
| 291 |
+
|
| 292 |
+
console.log(`\n[apply-spec] ${client} -> ${slug}-tse/ in ${((Date.now() - t0) / 1000).toFixed(2)}s`);
|
| 293 |
+
if (f.action !== false)
|
| 294 |
+
console.log('[apply-spec] NOTE: the custom-action modal (BidModal) is generic — customize it by hand for a bespoke workflow.');
|
demoprep_app/portal/specs/triumph.json
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"client": "TriumphFinancial",
|
| 3 |
+
"slug": "triumph",
|
| 4 |
+
"host": "sebe.thoughtspotstaging.cloud",
|
| 5 |
+
"auth": "none",
|
| 6 |
+
"ids": {
|
| 7 |
+
"analyticsLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea",
|
| 8 |
+
"model": "276f6862-3fe1-4df8-9ae0-12912c7f54c0",
|
| 9 |
+
"inlineLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea",
|
| 10 |
+
"actionLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea",
|
| 11 |
+
"actionViz": "ca684d71-dd94-4d70-a24a-efb2e443f9ea"
|
| 12 |
+
},
|
| 13 |
+
"action": {
|
| 14 |
+
"id": "request-capacity",
|
| 15 |
+
"name": "Request Capacity"
|
| 16 |
+
},
|
| 17 |
+
"columns": {
|
| 18 |
+
"inlineName": [
|
| 19 |
+
"Broker Name",
|
| 20 |
+
"Broker Tier"
|
| 21 |
+
],
|
| 22 |
+
"inlineMetrics": [
|
| 23 |
+
{
|
| 24 |
+
"label": "Load Revenue",
|
| 25 |
+
"format": "currency",
|
| 26 |
+
"candidates": [
|
| 27 |
+
"Total Load Revenue Usd",
|
| 28 |
+
"Total Load Revenue"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"label": "Broker Margin %",
|
| 33 |
+
"format": "number",
|
| 34 |
+
"candidates": [
|
| 35 |
+
"Broker Margin Pct",
|
| 36 |
+
"Broker Margin"
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
],
|
| 40 |
+
"filterPrimary": [
|
| 41 |
+
"Broker Name",
|
| 42 |
+
"Broker Tier"
|
| 43 |
+
],
|
| 44 |
+
"filterSecondary": [
|
| 45 |
+
"Lane Name",
|
| 46 |
+
"Lane Tier"
|
| 47 |
+
],
|
| 48 |
+
"date": "Load Date",
|
| 49 |
+
"hierarchy": {
|
| 50 |
+
"segment": "Broker Tier",
|
| 51 |
+
"rep": "Broker Name",
|
| 52 |
+
"cadence": "Lane Name"
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"logo": {
|
| 56 |
+
"svgPath": "triumph-logo.svg"
|
| 57 |
+
},
|
| 58 |
+
"favicon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\" width=\"32\" height=\"32\"><rect width=\"32\" height=\"32\" rx=\"7\" fill=\"#10294a\"/><path d=\"M8 8 H24 V13 H18.5 V26 H13.5 V13 H8 Z\" fill=\"#e8b53c\"/></svg>",
|
| 59 |
+
"theme": {
|
| 60 |
+
"default": "light",
|
| 61 |
+
"light": {
|
| 62 |
+
"--sl-cream": "#f5f8fc",
|
| 63 |
+
"--sl-cream-2": "#eaf1f8",
|
| 64 |
+
"--sl-white": "#ffffff",
|
| 65 |
+
"--sl-evergreen": "#10294a",
|
| 66 |
+
"--sl-evergreen-2": "#0a1d37",
|
| 67 |
+
"--sl-green": "#10294a",
|
| 68 |
+
"--sl-green-dark": "#1a3f6e",
|
| 69 |
+
"--sl-primary-rgb": "16, 41, 74",
|
| 70 |
+
"--intime-blue": "#2e86d1",
|
| 71 |
+
"--sl-gold": "#e8b53c",
|
| 72 |
+
"--sl-gold-ink": "#5f4708",
|
| 73 |
+
"--intime-grad": "linear-gradient(90deg, #10294a 0%, #2e86d1 100%)",
|
| 74 |
+
"--sl-mint": "#e5f2fc",
|
| 75 |
+
"--sl-mint-2": "#d2e8fa",
|
| 76 |
+
"--sl-coral": "#a2191f",
|
| 77 |
+
"--sl-warn": "#fffbe6",
|
| 78 |
+
"--sl-warn-ink": "#5f4708",
|
| 79 |
+
"--sl-ink": "#11253f",
|
| 80 |
+
"--sl-muted": "#5a6a7e",
|
| 81 |
+
"--sl-border": "#e0e8f0",
|
| 82 |
+
"--sl-border-soft": "#edf2f8",
|
| 83 |
+
"--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 84 |
+
"--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 85 |
+
"--topbar-h": "66px",
|
| 86 |
+
"--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)",
|
| 87 |
+
"--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)",
|
| 88 |
+
"--brand-navy": "#10294a",
|
| 89 |
+
"--brand-accent": "#2e86d1"
|
| 90 |
+
},
|
| 91 |
+
"dark": {
|
| 92 |
+
"--sl-cream": "#0a1a2e",
|
| 93 |
+
"--sl-cream-2": "#0e2238",
|
| 94 |
+
"--sl-white": "#122c48",
|
| 95 |
+
"--sl-evergreen": "#eef4fa",
|
| 96 |
+
"--sl-evergreen-2": "#dbe6f0",
|
| 97 |
+
"--sl-green": "#7ab4e8",
|
| 98 |
+
"--sl-green-dark": "#5a9bd6",
|
| 99 |
+
"--sl-primary-rgb": "122, 180, 232",
|
| 100 |
+
"--intime-blue": "#7ab4e8",
|
| 101 |
+
"--sl-gold": "#e8b53c",
|
| 102 |
+
"--sl-gold-ink": "#0a1a2e",
|
| 103 |
+
"--intime-grad": "linear-gradient(90deg, #7ab4e8 0%, #bfe0f7 100%)",
|
| 104 |
+
"--sl-mint": "#0f2a42",
|
| 105 |
+
"--sl-mint-2": "#153450",
|
| 106 |
+
"--sl-coral": "#f0616a",
|
| 107 |
+
"--sl-warn": "#2a2413",
|
| 108 |
+
"--sl-warn-ink": "#e8b53c",
|
| 109 |
+
"--sl-ink": "#eaf1f7",
|
| 110 |
+
"--sl-muted": "#9fb2c6",
|
| 111 |
+
"--sl-border": "#22405e",
|
| 112 |
+
"--sl-border-soft": "#162e48",
|
| 113 |
+
"--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 114 |
+
"--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 115 |
+
"--topbar-h": "66px",
|
| 116 |
+
"--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)",
|
| 117 |
+
"--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)",
|
| 118 |
+
"--brand-navy": "#10294a",
|
| 119 |
+
"--brand-accent": "#2e86d1"
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
"embedSwaps": [
|
| 123 |
+
["#4F5BD5", "#2e86d1"],
|
| 124 |
+
["#3F49B8", "#2673b8"],
|
| 125 |
+
["#333C99", "#1f5f99"],
|
| 126 |
+
["#22B8CF", "#7ab4e8"],
|
| 127 |
+
["#0b0d10", "#0a1a2e"],
|
| 128 |
+
["#14181f", "#122c48"],
|
| 129 |
+
["#1a212b", "#153450"],
|
| 130 |
+
["#232a34", "#22405e"],
|
| 131 |
+
["#0b1f19", "#0e2238"],
|
| 132 |
+
["rgba(45,164,191,0.14)", "rgba(122,180,232,0.16)"]
|
| 133 |
+
],
|
| 134 |
+
"greenSwaps": [
|
| 135 |
+
["#0f9a63", "#153450"],
|
| 136 |
+
["#0c8554", "#122c48"],
|
| 137 |
+
["#d9ecea", "#d2e8fa"],
|
| 138 |
+
["#cfe3e2", "#c0ddf6"],
|
| 139 |
+
["#1bb978", "#7ab4e8"],
|
| 140 |
+
["#15ae6e", "#10294a"]
|
| 141 |
+
],
|
| 142 |
+
"cssSwaps": [
|
| 143 |
+
["#0e1116", "#0e2238"],
|
| 144 |
+
["#10222a", "#0e2238"],
|
| 145 |
+
["#123047", "#122c48"]
|
| 146 |
+
],
|
| 147 |
+
"features": {
|
| 148 |
+
"askMode": "both",
|
| 149 |
+
"inline": false,
|
| 150 |
+
"action": false,
|
| 151 |
+
"monetize": false,
|
| 152 |
+
"tiers": false,
|
| 153 |
+
"pinning": false
|
| 154 |
+
},
|
| 155 |
+
"content": {
|
| 156 |
+
"company": "Triumph Financial",
|
| 157 |
+
"aiName": "Triumph AI",
|
| 158 |
+
"website": "https://www.triumph.io",
|
| 159 |
+
"loginTitleHtml": "Freight payments,<br />intelligently moved.",
|
| 160 |
+
"loginSubtitle": "Your broker capacity command center — loads, lanes, rates, margins and carrier capacity across the TriumphPay network, with AI-powered answers on live data. Built on Triumph Financial, powered by ThoughtSpot.",
|
| 161 |
+
"loginStats": [
|
| 162 |
+
{ "value": "$30B+", "label": "Payments Network" },
|
| 163 |
+
{ "value": "Live", "label": "Lane & Rate Intel" },
|
| 164 |
+
{ "value": "Real-time", "label": "Capacity Signals" }
|
| 165 |
+
],
|
| 166 |
+
"loginCardSubtitle": "Sign in with your ThoughtSpot credentials",
|
| 167 |
+
"loginDemoNoteHtml": "<strong>Sign in</strong> with your ThoughtSpot username & password for <code>{host}</code>. This origin must be CORS-allowlisted on that cluster for the embeds to load.",
|
| 168 |
+
"platformNav": [
|
| 169 |
+
"Brokers",
|
| 170 |
+
"Carriers",
|
| 171 |
+
"Lanes"
|
| 172 |
+
],
|
| 173 |
+
"tabs": {
|
| 174 |
+
"myReports": "My Reports",
|
| 175 |
+
"analytics": "Capacity Analytics",
|
| 176 |
+
"inline": "Brokers",
|
| 177 |
+
"action": "Capacity",
|
| 178 |
+
"ask": "Ask Triumph AI",
|
| 179 |
+
"spotter": "Spotter"
|
| 180 |
+
},
|
| 181 |
+
"analyticsSubtitle": "Your live broker, lane & carrier-capacity dashboard",
|
| 182 |
+
"spotterSubtitle": "Ask questions of your Triumph Financial freight & capacity data in natural language.",
|
| 183 |
+
"inline": {
|
| 184 |
+
"subtitleReady": "Revenue & margin by broker — select a broker to view its lane insights",
|
| 185 |
+
"subtitleIdle": "Revenue and margin across your broker network",
|
| 186 |
+
"loading": "Loading brokers…",
|
| 187 |
+
"errorTitle": "Couldn't load brokers",
|
| 188 |
+
"errorMsg": "Unable to load broker data.",
|
| 189 |
+
"moreLabel": "View insights",
|
| 190 |
+
"hideLabel": "Hide insights"
|
| 191 |
+
},
|
| 192 |
+
"filters": {
|
| 193 |
+
"primaryLabel": "Broker",
|
| 194 |
+
"secondaryLabel": "Lane"
|
| 195 |
+
},
|
| 196 |
+
"sampleQuestions": [
|
| 197 |
+
"Broker margin by broker tier",
|
| 198 |
+
"Load revenue by lane over time",
|
| 199 |
+
"Capacity utilization rate by month",
|
| 200 |
+
"On time delivery rate by carrier"
|
| 201 |
+
],
|
| 202 |
+
"askWelcome": "Ask me anything about Triumph Financial — loads, lanes, rates, broker margins, carrier capacity — or ask about your own data and I will chart it live.",
|
| 203 |
+
"askEmptySub": "Ask any analytical question about your loads, lanes, broker margins, and carrier capacity. Answers come straight from your live data with an interactive chart you can drill into.",
|
| 204 |
+
"action": {
|
| 205 |
+
"label": "Request Capacity",
|
| 206 |
+
"subtitlePrefix": "Loads by broker and lane. Right-click a row and choose",
|
| 207 |
+
"subtitleSuffix": "to open a capacity request pre-filled for that lane.",
|
| 208 |
+
"modalTitle": "New Capacity Request",
|
| 209 |
+
"modalLead": "Pre-filled for the selected lane. Review and submit to request carrier capacity through TriumphPay.",
|
| 210 |
+
"submitLabel": "Submit",
|
| 211 |
+
"successTitle": "Capacity request submitted"
|
| 212 |
+
},
|
| 213 |
+
"chatbot": {
|
| 214 |
+
"welcome": "Hi! I'm Triumph AI. Ask me about loads, lanes, broker margins and carrier capacity, or ask a question about your data.",
|
| 215 |
+
"overview": "Triumph Financial is a transportation-fintech company: TBK Bank, Triumph Business Capital (invoice factoring for carriers), and TriumphPay, a freight-payments network digitizing invoice presentment, audit and settlement among brokers, carriers, shippers and factors. This portal covers broker capacity intelligence — loads, lanes, rates, margins and carrier capacity — with AI-powered answers on live data.",
|
| 216 |
+
"greetingExample": "broker margin by tier",
|
| 217 |
+
"examples": [
|
| 218 |
+
"load revenue by lane",
|
| 219 |
+
"capacity utilization by month"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
"trialQuestionTrigger": 3,
|
| 223 |
+
"upgradeReason": "Upgrade to TriumphPay Premium to unlock drill-down, Ask AI and downloads.",
|
| 224 |
+
"analyticsKeywords": [
|
| 225 |
+
"load",
|
| 226 |
+
"loads",
|
| 227 |
+
"lane",
|
| 228 |
+
"broker",
|
| 229 |
+
"carrier",
|
| 230 |
+
"margin",
|
| 231 |
+
"revenue",
|
| 232 |
+
"rate",
|
| 233 |
+
"capacity",
|
| 234 |
+
"utilization",
|
| 235 |
+
"dispute",
|
| 236 |
+
"on-time"
|
| 237 |
+
]
|
| 238 |
+
}
|
| 239 |
+
}
|
demoprep_app/portal/specs/vantage.json
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"client": "VantageCapital",
|
| 3 |
+
"slug": "vantage",
|
| 4 |
+
"host": "sebe.thoughtspotstaging.cloud",
|
| 5 |
+
"auth": "none",
|
| 6 |
+
"ids": {
|
| 7 |
+
"analyticsLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d",
|
| 8 |
+
"model": "d9d910d7-d3b5-4a7a-a35e-de494405055f",
|
| 9 |
+
"inlineLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d",
|
| 10 |
+
"actionLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d",
|
| 11 |
+
"actionViz": "a476cb7d-5b61-4658-86ca-5f078a212a0d"
|
| 12 |
+
},
|
| 13 |
+
"action": {
|
| 14 |
+
"id": "flag-for-review",
|
| 15 |
+
"name": "Flag for Review"
|
| 16 |
+
},
|
| 17 |
+
"columns": {
|
| 18 |
+
"inlineName": [
|
| 19 |
+
"Borrower Industry Name",
|
| 20 |
+
"Industry Sector"
|
| 21 |
+
],
|
| 22 |
+
"inlineMetrics": [
|
| 23 |
+
{
|
| 24 |
+
"label": "Outstanding Balance",
|
| 25 |
+
"format": "currency",
|
| 26 |
+
"candidates": [
|
| 27 |
+
"Outstanding Balance Usd",
|
| 28 |
+
"Outstanding Balance"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"label": "Delinquency Rate",
|
| 33 |
+
"format": "number",
|
| 34 |
+
"candidates": [
|
| 35 |
+
"Delinquency Rate"
|
| 36 |
+
]
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"filterPrimary": [
|
| 40 |
+
"Borrower Industry Name",
|
| 41 |
+
"Industry Sector"
|
| 42 |
+
],
|
| 43 |
+
"filterSecondary": [
|
| 44 |
+
"Risk Grade Name",
|
| 45 |
+
"Grade Tier"
|
| 46 |
+
],
|
| 47 |
+
"date": "Snapshot Month",
|
| 48 |
+
"hierarchy": {
|
| 49 |
+
"segment": "Borrower Industry Name",
|
| 50 |
+
"rep": "Risk Grade Name",
|
| 51 |
+
"cadence": "Region Name"
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
"logo": {
|
| 55 |
+
"svgPath": "vantage-logo.svg"
|
| 56 |
+
},
|
| 57 |
+
"favicon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\" width=\"32\" height=\"32\"><rect width=\"32\" height=\"32\" rx=\"7\" fill=\"#12283f\"/><path d=\"M8 8 L16 26 L24 8 L19 8 L16 16 L13 8 Z\" fill=\"#f4d14e\"/></svg>",
|
| 58 |
+
"theme": {
|
| 59 |
+
"default": "light",
|
| 60 |
+
"light": {
|
| 61 |
+
"--sl-cream": "#f4f7fb",
|
| 62 |
+
"--sl-cream-2": "#e9f0f7",
|
| 63 |
+
"--sl-white": "#ffffff",
|
| 64 |
+
"--sl-evergreen": "#12283f",
|
| 65 |
+
"--sl-evergreen-2": "#0c1d30",
|
| 66 |
+
"--sl-green": "#12283f",
|
| 67 |
+
"--sl-green-dark": "#1b3f63",
|
| 68 |
+
"--sl-primary-rgb": "18, 40, 63",
|
| 69 |
+
"--intime-blue": "#2f6fb0",
|
| 70 |
+
"--sl-gold": "#f4d14e",
|
| 71 |
+
"--sl-gold-ink": "#6b5410",
|
| 72 |
+
"--intime-grad": "linear-gradient(90deg, #12283f 0%, #2f6fb0 100%)",
|
| 73 |
+
"--sl-mint": "#e6f1fb",
|
| 74 |
+
"--sl-mint-2": "#d5e8f8",
|
| 75 |
+
"--sl-coral": "#a2191f",
|
| 76 |
+
"--sl-warn": "#fffbe6",
|
| 77 |
+
"--sl-warn-ink": "#6b5410",
|
| 78 |
+
"--sl-ink": "#12283f",
|
| 79 |
+
"--sl-muted": "#5a6a7e",
|
| 80 |
+
"--sl-border": "#e1e8f0",
|
| 81 |
+
"--sl-border-soft": "#eef2f7",
|
| 82 |
+
"--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 83 |
+
"--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 84 |
+
"--topbar-h": "66px",
|
| 85 |
+
"--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)",
|
| 86 |
+
"--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)",
|
| 87 |
+
"--brand-navy": "#12283f",
|
| 88 |
+
"--brand-accent": "#2f6fb0"
|
| 89 |
+
},
|
| 90 |
+
"dark": {
|
| 91 |
+
"--sl-cream": "#0a1a2b",
|
| 92 |
+
"--sl-cream-2": "#0e2236",
|
| 93 |
+
"--sl-white": "#122c44",
|
| 94 |
+
"--sl-evergreen": "#eef4fa",
|
| 95 |
+
"--sl-evergreen-2": "#dbe6f0",
|
| 96 |
+
"--sl-green": "#7fb0e0",
|
| 97 |
+
"--sl-green-dark": "#5e97cc",
|
| 98 |
+
"--sl-primary-rgb": "127, 176, 224",
|
| 99 |
+
"--intime-blue": "#7fb0e0",
|
| 100 |
+
"--sl-gold": "#f4d14e",
|
| 101 |
+
"--sl-gold-ink": "#0a1a2b",
|
| 102 |
+
"--intime-grad": "linear-gradient(90deg, #7fb0e0 0%, #bfe0f5 100%)",
|
| 103 |
+
"--sl-mint": "#10293d",
|
| 104 |
+
"--sl-mint-2": "#16344c",
|
| 105 |
+
"--sl-coral": "#f0616a",
|
| 106 |
+
"--sl-warn": "#2a2413",
|
| 107 |
+
"--sl-warn-ink": "#f4d14e",
|
| 108 |
+
"--sl-ink": "#eaf1f7",
|
| 109 |
+
"--sl-muted": "#9fb2c6",
|
| 110 |
+
"--sl-border": "#23415a",
|
| 111 |
+
"--sl-border-soft": "#172f44",
|
| 112 |
+
"--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 113 |
+
"--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
| 114 |
+
"--topbar-h": "66px",
|
| 115 |
+
"--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)",
|
| 116 |
+
"--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)",
|
| 117 |
+
"--brand-navy": "#12283f",
|
| 118 |
+
"--brand-accent": "#2f6fb0"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"embedSwaps": [
|
| 122 |
+
["#4F5BD5", "#2f6fb0"],
|
| 123 |
+
["#3F49B8", "#285f97"],
|
| 124 |
+
["#333C99", "#22527f"],
|
| 125 |
+
["#22B8CF", "#7fb0e0"],
|
| 126 |
+
["#0b0d10", "#0a1a2b"],
|
| 127 |
+
["#14181f", "#122c44"],
|
| 128 |
+
["#1a212b", "#16344c"],
|
| 129 |
+
["#232a34", "#23415a"],
|
| 130 |
+
["#0b1f19", "#0e2236"],
|
| 131 |
+
["rgba(45,164,191,0.14)", "rgba(127,176,224,0.16)"]
|
| 132 |
+
],
|
| 133 |
+
"greenSwaps": [
|
| 134 |
+
["#0f9a63", "#16344c"],
|
| 135 |
+
["#0c8554", "#122c44"],
|
| 136 |
+
["#d9ecea", "#d5e8f8"],
|
| 137 |
+
["#cfe3e2", "#c2ddf4"],
|
| 138 |
+
["#1bb978", "#7fb0e0"],
|
| 139 |
+
["#15ae6e", "#12283f"]
|
| 140 |
+
],
|
| 141 |
+
"cssSwaps": [
|
| 142 |
+
["#0e1116", "#0e2236"],
|
| 143 |
+
["#10222a", "#0e2236"],
|
| 144 |
+
["#123047", "#122c44"]
|
| 145 |
+
],
|
| 146 |
+
"features": {
|
| 147 |
+
"askMode": "both",
|
| 148 |
+
"inline": false,
|
| 149 |
+
"action": false,
|
| 150 |
+
"monetize": false,
|
| 151 |
+
"tiers": false,
|
| 152 |
+
"pinning": false
|
| 153 |
+
},
|
| 154 |
+
"content": {
|
| 155 |
+
"company": "Vantage Capital",
|
| 156 |
+
"aiName": "Vantage AI",
|
| 157 |
+
"website": "https://www.vantagecapital.example.com",
|
| 158 |
+
"loginTitleHtml": "Credit risk,<br />seen clearly.",
|
| 159 |
+
"loginSubtitle": "Your portfolio command center for commercial lending — outstanding balances, delinquency, charge-offs and provision coverage, with AI-powered answers on live data. Built on Vantage Capital, powered by ThoughtSpot.",
|
| 160 |
+
"loginStats": [
|
| 161 |
+
{ "value": "$4.2B", "label": "Portfolio Managed" },
|
| 162 |
+
{ "value": "7", "label": "Risk Grades Tracked" },
|
| 163 |
+
{ "value": "Real-time", "label": "Delinquency Intel" }
|
| 164 |
+
],
|
| 165 |
+
"loginCardSubtitle": "Sign in with your ThoughtSpot credentials",
|
| 166 |
+
"loginDemoNoteHtml": "<strong>Sign in</strong> with your ThoughtSpot username & password for <code>{host}</code>. This origin must be CORS-allowlisted on that cluster for the embeds to load.",
|
| 167 |
+
"platformNav": [
|
| 168 |
+
"Portfolio",
|
| 169 |
+
"Credit Risk",
|
| 170 |
+
"Reviews"
|
| 171 |
+
],
|
| 172 |
+
"tabs": {
|
| 173 |
+
"myReports": "My Reports",
|
| 174 |
+
"analytics": "Portfolio Analytics",
|
| 175 |
+
"inline": "Industries",
|
| 176 |
+
"action": "Reviews",
|
| 177 |
+
"ask": "Ask Vantage AI",
|
| 178 |
+
"spotter": "Spotter"
|
| 179 |
+
},
|
| 180 |
+
"analyticsSubtitle": "Your live portfolio, delinquency & credit-risk dashboard",
|
| 181 |
+
"spotterSubtitle": "Ask questions of your Vantage Capital portfolio & credit-risk data in natural language.",
|
| 182 |
+
"inline": {
|
| 183 |
+
"subtitleReady": "Balance & delinquency by industry — select an industry to view its insights",
|
| 184 |
+
"subtitleIdle": "Balance and delinquency across borrower industries",
|
| 185 |
+
"loading": "Loading industries…",
|
| 186 |
+
"errorTitle": "Couldn't load industries",
|
| 187 |
+
"errorMsg": "Unable to load industry data.",
|
| 188 |
+
"moreLabel": "View insights",
|
| 189 |
+
"hideLabel": "Hide insights"
|
| 190 |
+
},
|
| 191 |
+
"filters": {
|
| 192 |
+
"primaryLabel": "Industry",
|
| 193 |
+
"secondaryLabel": "Risk Grade"
|
| 194 |
+
},
|
| 195 |
+
"sampleQuestions": [
|
| 196 |
+
"Delinquency rate by borrower industry",
|
| 197 |
+
"Outstanding balance by risk grade",
|
| 198 |
+
"Charge off rate by region over time",
|
| 199 |
+
"Provision coverage ratio by product family"
|
| 200 |
+
],
|
| 201 |
+
"askWelcome": "Ask me anything about Vantage Capital — portfolio exposure, delinquency, charge-offs, credit reviews — or ask about your own data and I will chart it live.",
|
| 202 |
+
"askEmptySub": "Ask any analytical question about your loan portfolio, delinquency, risk grades, and credit reviews. Answers come straight from your live data with an interactive chart you can drill into.",
|
| 203 |
+
"action": {
|
| 204 |
+
"label": "Flag for Review",
|
| 205 |
+
"subtitlePrefix": "Loan exposures by industry and grade. Right-click a row and choose",
|
| 206 |
+
"subtitleSuffix": "to open a credit review pre-filled for that exposure.",
|
| 207 |
+
"modalTitle": "New Credit Review",
|
| 208 |
+
"modalLead": "Pre-filled for the selected exposure. Review and submit to open a credit review in Vantage.",
|
| 209 |
+
"submitLabel": "Submit",
|
| 210 |
+
"successTitle": "Credit review opened"
|
| 211 |
+
},
|
| 212 |
+
"chatbot": {
|
| 213 |
+
"welcome": "Hi! I'm Vantage AI. Ask me about portfolio exposure, delinquency and credit risk, or ask a question about your data.",
|
| 214 |
+
"overview": "Vantage Capital is a commercial lender. This portal covers portfolio and credit-risk analytics — outstanding balances, exposure at default, delinquency and charge-off rates, provision coverage, and credit review activity — with AI-powered answers on live portfolio data.",
|
| 215 |
+
"greetingExample": "delinquency rate by industry",
|
| 216 |
+
"examples": [
|
| 217 |
+
"outstanding balance by risk grade",
|
| 218 |
+
"charge off rate by region"
|
| 219 |
+
]
|
| 220 |
+
},
|
| 221 |
+
"trialQuestionTrigger": 3,
|
| 222 |
+
"upgradeReason": "Upgrade to Vantage Premium to unlock drill-down, Ask AI and downloads.",
|
| 223 |
+
"analyticsKeywords": [
|
| 224 |
+
"delinquency",
|
| 225 |
+
"balance",
|
| 226 |
+
"exposure",
|
| 227 |
+
"charge-off",
|
| 228 |
+
"provision",
|
| 229 |
+
"risk",
|
| 230 |
+
"grade",
|
| 231 |
+
"industry",
|
| 232 |
+
"region",
|
| 233 |
+
"loan",
|
| 234 |
+
"portfolio",
|
| 235 |
+
"review"
|
| 236 |
+
]
|
| 237 |
+
}
|
| 238 |
+
}
|
demoprep_app/portal/template-tse/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
.DS_Store
|
| 4 |
+
*.local
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
!.env.example
|
| 8 |
+
.vercel/
|
| 9 |
+
|
| 10 |
+
# Large local recordings
|
| 11 |
+
*.mov
|
| 12 |
+
|
| 13 |
+
# Unrelated demos / local tooling kept in this working dir (repo = Salesloft app only)
|
| 14 |
+
clari/
|
| 15 |
+
hirevue/
|
| 16 |
+
hivewatch/
|
| 17 |
+
simfoni/
|
| 18 |
+
output/
|
| 19 |
+
.vercel
|
| 20 |
+
.env*
|
demoprep_app/portal/template-tse/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Acme × ThoughtSpot — Embedded Analytics Portal
|
| 2 |
+
|
| 3 |
+
A demo revenue-intelligence portal styled as an **Acme** product surface, with
|
| 4 |
+
ThoughtSpot embedded throughout via the
|
| 5 |
+
[Visual Embed SDK](https://developers.thoughtspot.com/docs). Built with Vite +
|
| 6 |
+
React + TypeScript. Deploys to Vercel.
|
| 7 |
+
|
| 8 |
+
This is a rebranded twin of the Salesloft demo — identical functionality, theme,
|
| 9 |
+
and ThoughtSpot data; only the logo and the **Acme / Acme AI** branding differ.
|
| 10 |
+
|
| 11 |
+
## What's in it
|
| 12 |
+
|
| 13 |
+
| Tab | ThoughtSpot embed | Notes |
|
| 14 |
+
| --- | --- | --- |
|
| 15 |
+
| **My Reports** | — | Landing / overview |
|
| 16 |
+
| **Analytics** | `LiveboardEmbed` | Full liveboard with a **Rep** dropdown runtime filter (`HostEvent.UpdateRuntimeFilters`) |
|
| 17 |
+
| **Cadences** | — | Static view |
|
| 18 |
+
| **Signals** | `LiveboardEmbed` (single viz) | One visualization from the Analytics liveboard; right-click a row → **Re-engage cadence** custom action opens a Win-Back Cadence modal |
|
| 19 |
+
| **Ask Acme** | `SpotterEmbed` | "Acme AI" chat: general questions answered from a small FAQ/knowledge base, data questions routed to Spotter via `HostEvent.SpotterSearch`; playful trial-upgrade modal |
|
| 20 |
+
|
| 21 |
+
## Getting started
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
npm install
|
| 25 |
+
npm run dev # local dev server
|
| 26 |
+
npm run build # tsc + vite build -> dist/
|
| 27 |
+
npm run preview # preview the production build
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
> If `node`/`npm` aren't on your PATH, they may live at `~/.node/bin`:
|
| 31 |
+
> `export PATH="$HOME/.node/bin:$PATH"`.
|
| 32 |
+
|
| 33 |
+
## Configuration
|
| 34 |
+
|
| 35 |
+
- **ThoughtSpot IDs & flags** live in [`src/config.ts`](src/config.ts).
|
| 36 |
+
- **ThoughtSpot host** and auth are set in [`src/lib/thoughtspot.ts`](src/lib/thoughtspot.ts).
|
| 37 |
+
- **Optional LLM routing** for Ask Acme — copy `.env.example` to `.env` and set
|
| 38 |
+
`VITE_ANTHROPIC_API_KEY`. Without it, the chatbot falls back to a keyword
|
| 39 |
+
router + FAQ and still routes data questions to Spotter.
|
| 40 |
+
|
| 41 |
+
## Deploy
|
| 42 |
+
|
| 43 |
+
Configured for Vercel ([`vercel.json`](vercel.json)) — framework `vite`,
|
| 44 |
+
output `dist/`, SPA rewrite to `/index.html`.
|
demoprep_app/portal/template-tse/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/northwind-icon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Northwind Analytics</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link
|
| 11 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
| 12 |
+
rel="stylesheet"
|
| 13 |
+
/>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<div id="root"></div>
|
| 17 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
demoprep_app/portal/template-tse/package-lock.json
ADDED
|
@@ -0,0 +1,1934 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "northwind-thoughtspot-portal",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "northwind-thoughtspot-portal",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"@thoughtspot/visual-embed-sdk": "^1.37.0",
|
| 12 |
+
"@vercel/analytics": "^2.0.1",
|
| 13 |
+
"lucide-react": "^0.300.0",
|
| 14 |
+
"react": "^18.2.0",
|
| 15 |
+
"react-dom": "^18.2.0",
|
| 16 |
+
"react-router-dom": "^6.21.0"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/react": "^18.2.45",
|
| 20 |
+
"@types/react-dom": "^18.2.18",
|
| 21 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 22 |
+
"typescript": "^5.3.3",
|
| 23 |
+
"vite": "^5.0.10"
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
"node_modules/@babel/code-frame": {
|
| 27 |
+
"version": "7.29.0",
|
| 28 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
| 29 |
+
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
| 30 |
+
"dev": true,
|
| 31 |
+
"license": "MIT",
|
| 32 |
+
"dependencies": {
|
| 33 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 34 |
+
"js-tokens": "^4.0.0",
|
| 35 |
+
"picocolors": "^1.1.1"
|
| 36 |
+
},
|
| 37 |
+
"engines": {
|
| 38 |
+
"node": ">=6.9.0"
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
"node_modules/@babel/compat-data": {
|
| 42 |
+
"version": "7.29.0",
|
| 43 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
| 44 |
+
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
| 45 |
+
"dev": true,
|
| 46 |
+
"license": "MIT",
|
| 47 |
+
"engines": {
|
| 48 |
+
"node": ">=6.9.0"
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
"node_modules/@babel/core": {
|
| 52 |
+
"version": "7.29.0",
|
| 53 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 54 |
+
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 55 |
+
"dev": true,
|
| 56 |
+
"license": "MIT",
|
| 57 |
+
"dependencies": {
|
| 58 |
+
"@babel/code-frame": "^7.29.0",
|
| 59 |
+
"@babel/generator": "^7.29.0",
|
| 60 |
+
"@babel/helper-compilation-targets": "^7.28.6",
|
| 61 |
+
"@babel/helper-module-transforms": "^7.28.6",
|
| 62 |
+
"@babel/helpers": "^7.28.6",
|
| 63 |
+
"@babel/parser": "^7.29.0",
|
| 64 |
+
"@babel/template": "^7.28.6",
|
| 65 |
+
"@babel/traverse": "^7.29.0",
|
| 66 |
+
"@babel/types": "^7.29.0",
|
| 67 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 68 |
+
"convert-source-map": "^2.0.0",
|
| 69 |
+
"debug": "^4.1.0",
|
| 70 |
+
"gensync": "^1.0.0-beta.2",
|
| 71 |
+
"json5": "^2.2.3",
|
| 72 |
+
"semver": "^6.3.1"
|
| 73 |
+
},
|
| 74 |
+
"engines": {
|
| 75 |
+
"node": ">=6.9.0"
|
| 76 |
+
},
|
| 77 |
+
"funding": {
|
| 78 |
+
"type": "opencollective",
|
| 79 |
+
"url": "https://opencollective.com/babel"
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
"node_modules/@babel/generator": {
|
| 83 |
+
"version": "7.29.1",
|
| 84 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
| 85 |
+
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
| 86 |
+
"dev": true,
|
| 87 |
+
"license": "MIT",
|
| 88 |
+
"dependencies": {
|
| 89 |
+
"@babel/parser": "^7.29.0",
|
| 90 |
+
"@babel/types": "^7.29.0",
|
| 91 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 92 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 93 |
+
"jsesc": "^3.0.2"
|
| 94 |
+
},
|
| 95 |
+
"engines": {
|
| 96 |
+
"node": ">=6.9.0"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 100 |
+
"version": "7.28.6",
|
| 101 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
| 102 |
+
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
| 103 |
+
"dev": true,
|
| 104 |
+
"license": "MIT",
|
| 105 |
+
"dependencies": {
|
| 106 |
+
"@babel/compat-data": "^7.28.6",
|
| 107 |
+
"@babel/helper-validator-option": "^7.27.1",
|
| 108 |
+
"browserslist": "^4.24.0",
|
| 109 |
+
"lru-cache": "^5.1.1",
|
| 110 |
+
"semver": "^6.3.1"
|
| 111 |
+
},
|
| 112 |
+
"engines": {
|
| 113 |
+
"node": ">=6.9.0"
|
| 114 |
+
}
|
| 115 |
+
},
|
| 116 |
+
"node_modules/@babel/helper-globals": {
|
| 117 |
+
"version": "7.28.0",
|
| 118 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
| 119 |
+
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
| 120 |
+
"dev": true,
|
| 121 |
+
"license": "MIT",
|
| 122 |
+
"engines": {
|
| 123 |
+
"node": ">=6.9.0"
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
"node_modules/@babel/helper-module-imports": {
|
| 127 |
+
"version": "7.28.6",
|
| 128 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
| 129 |
+
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
| 130 |
+
"dev": true,
|
| 131 |
+
"license": "MIT",
|
| 132 |
+
"dependencies": {
|
| 133 |
+
"@babel/traverse": "^7.28.6",
|
| 134 |
+
"@babel/types": "^7.28.6"
|
| 135 |
+
},
|
| 136 |
+
"engines": {
|
| 137 |
+
"node": ">=6.9.0"
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 141 |
+
"version": "7.28.6",
|
| 142 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
| 143 |
+
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
| 144 |
+
"dev": true,
|
| 145 |
+
"license": "MIT",
|
| 146 |
+
"dependencies": {
|
| 147 |
+
"@babel/helper-module-imports": "^7.28.6",
|
| 148 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 149 |
+
"@babel/traverse": "^7.28.6"
|
| 150 |
+
},
|
| 151 |
+
"engines": {
|
| 152 |
+
"node": ">=6.9.0"
|
| 153 |
+
},
|
| 154 |
+
"peerDependencies": {
|
| 155 |
+
"@babel/core": "^7.0.0"
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 159 |
+
"version": "7.28.6",
|
| 160 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
| 161 |
+
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
| 162 |
+
"dev": true,
|
| 163 |
+
"license": "MIT",
|
| 164 |
+
"engines": {
|
| 165 |
+
"node": ">=6.9.0"
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
"node_modules/@babel/helper-string-parser": {
|
| 169 |
+
"version": "7.27.1",
|
| 170 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
| 171 |
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
| 172 |
+
"dev": true,
|
| 173 |
+
"license": "MIT",
|
| 174 |
+
"engines": {
|
| 175 |
+
"node": ">=6.9.0"
|
| 176 |
+
}
|
| 177 |
+
},
|
| 178 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 179 |
+
"version": "7.28.5",
|
| 180 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
| 181 |
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
| 182 |
+
"dev": true,
|
| 183 |
+
"license": "MIT",
|
| 184 |
+
"engines": {
|
| 185 |
+
"node": ">=6.9.0"
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
"node_modules/@babel/helper-validator-option": {
|
| 189 |
+
"version": "7.27.1",
|
| 190 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
| 191 |
+
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
| 192 |
+
"dev": true,
|
| 193 |
+
"license": "MIT",
|
| 194 |
+
"engines": {
|
| 195 |
+
"node": ">=6.9.0"
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"node_modules/@babel/helpers": {
|
| 199 |
+
"version": "7.29.2",
|
| 200 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
| 201 |
+
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
| 202 |
+
"dev": true,
|
| 203 |
+
"license": "MIT",
|
| 204 |
+
"dependencies": {
|
| 205 |
+
"@babel/template": "^7.28.6",
|
| 206 |
+
"@babel/types": "^7.29.0"
|
| 207 |
+
},
|
| 208 |
+
"engines": {
|
| 209 |
+
"node": ">=6.9.0"
|
| 210 |
+
}
|
| 211 |
+
},
|
| 212 |
+
"node_modules/@babel/parser": {
|
| 213 |
+
"version": "7.29.2",
|
| 214 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
| 215 |
+
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
| 216 |
+
"dev": true,
|
| 217 |
+
"license": "MIT",
|
| 218 |
+
"dependencies": {
|
| 219 |
+
"@babel/types": "^7.29.0"
|
| 220 |
+
},
|
| 221 |
+
"bin": {
|
| 222 |
+
"parser": "bin/babel-parser.js"
|
| 223 |
+
},
|
| 224 |
+
"engines": {
|
| 225 |
+
"node": ">=6.0.0"
|
| 226 |
+
}
|
| 227 |
+
},
|
| 228 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 229 |
+
"version": "7.27.1",
|
| 230 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
| 231 |
+
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
| 232 |
+
"dev": true,
|
| 233 |
+
"license": "MIT",
|
| 234 |
+
"dependencies": {
|
| 235 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 236 |
+
},
|
| 237 |
+
"engines": {
|
| 238 |
+
"node": ">=6.9.0"
|
| 239 |
+
},
|
| 240 |
+
"peerDependencies": {
|
| 241 |
+
"@babel/core": "^7.0.0-0"
|
| 242 |
+
}
|
| 243 |
+
},
|
| 244 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 245 |
+
"version": "7.27.1",
|
| 246 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
| 247 |
+
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
| 248 |
+
"dev": true,
|
| 249 |
+
"license": "MIT",
|
| 250 |
+
"dependencies": {
|
| 251 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 252 |
+
},
|
| 253 |
+
"engines": {
|
| 254 |
+
"node": ">=6.9.0"
|
| 255 |
+
},
|
| 256 |
+
"peerDependencies": {
|
| 257 |
+
"@babel/core": "^7.0.0-0"
|
| 258 |
+
}
|
| 259 |
+
},
|
| 260 |
+
"node_modules/@babel/runtime": {
|
| 261 |
+
"version": "7.29.2",
|
| 262 |
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
| 263 |
+
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
| 264 |
+
"license": "MIT",
|
| 265 |
+
"engines": {
|
| 266 |
+
"node": ">=6.9.0"
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
"node_modules/@babel/template": {
|
| 270 |
+
"version": "7.28.6",
|
| 271 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
| 272 |
+
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
| 273 |
+
"dev": true,
|
| 274 |
+
"license": "MIT",
|
| 275 |
+
"dependencies": {
|
| 276 |
+
"@babel/code-frame": "^7.28.6",
|
| 277 |
+
"@babel/parser": "^7.28.6",
|
| 278 |
+
"@babel/types": "^7.28.6"
|
| 279 |
+
},
|
| 280 |
+
"engines": {
|
| 281 |
+
"node": ">=6.9.0"
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
"node_modules/@babel/traverse": {
|
| 285 |
+
"version": "7.29.0",
|
| 286 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
| 287 |
+
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
| 288 |
+
"dev": true,
|
| 289 |
+
"license": "MIT",
|
| 290 |
+
"dependencies": {
|
| 291 |
+
"@babel/code-frame": "^7.29.0",
|
| 292 |
+
"@babel/generator": "^7.29.0",
|
| 293 |
+
"@babel/helper-globals": "^7.28.0",
|
| 294 |
+
"@babel/parser": "^7.29.0",
|
| 295 |
+
"@babel/template": "^7.28.6",
|
| 296 |
+
"@babel/types": "^7.29.0",
|
| 297 |
+
"debug": "^4.3.1"
|
| 298 |
+
},
|
| 299 |
+
"engines": {
|
| 300 |
+
"node": ">=6.9.0"
|
| 301 |
+
}
|
| 302 |
+
},
|
| 303 |
+
"node_modules/@babel/types": {
|
| 304 |
+
"version": "7.29.0",
|
| 305 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
| 306 |
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
| 307 |
+
"dev": true,
|
| 308 |
+
"license": "MIT",
|
| 309 |
+
"dependencies": {
|
| 310 |
+
"@babel/helper-string-parser": "^7.27.1",
|
| 311 |
+
"@babel/helper-validator-identifier": "^7.28.5"
|
| 312 |
+
},
|
| 313 |
+
"engines": {
|
| 314 |
+
"node": ">=6.9.0"
|
| 315 |
+
}
|
| 316 |
+
},
|
| 317 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 318 |
+
"version": "0.21.5",
|
| 319 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 320 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 321 |
+
"cpu": [
|
| 322 |
+
"ppc64"
|
| 323 |
+
],
|
| 324 |
+
"dev": true,
|
| 325 |
+
"license": "MIT",
|
| 326 |
+
"optional": true,
|
| 327 |
+
"os": [
|
| 328 |
+
"aix"
|
| 329 |
+
],
|
| 330 |
+
"engines": {
|
| 331 |
+
"node": ">=12"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
"node_modules/@esbuild/android-arm": {
|
| 335 |
+
"version": "0.21.5",
|
| 336 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 337 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 338 |
+
"cpu": [
|
| 339 |
+
"arm"
|
| 340 |
+
],
|
| 341 |
+
"dev": true,
|
| 342 |
+
"license": "MIT",
|
| 343 |
+
"optional": true,
|
| 344 |
+
"os": [
|
| 345 |
+
"android"
|
| 346 |
+
],
|
| 347 |
+
"engines": {
|
| 348 |
+
"node": ">=12"
|
| 349 |
+
}
|
| 350 |
+
},
|
| 351 |
+
"node_modules/@esbuild/android-arm64": {
|
| 352 |
+
"version": "0.21.5",
|
| 353 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 354 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 355 |
+
"cpu": [
|
| 356 |
+
"arm64"
|
| 357 |
+
],
|
| 358 |
+
"dev": true,
|
| 359 |
+
"license": "MIT",
|
| 360 |
+
"optional": true,
|
| 361 |
+
"os": [
|
| 362 |
+
"android"
|
| 363 |
+
],
|
| 364 |
+
"engines": {
|
| 365 |
+
"node": ">=12"
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
"node_modules/@esbuild/android-x64": {
|
| 369 |
+
"version": "0.21.5",
|
| 370 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 371 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 372 |
+
"cpu": [
|
| 373 |
+
"x64"
|
| 374 |
+
],
|
| 375 |
+
"dev": true,
|
| 376 |
+
"license": "MIT",
|
| 377 |
+
"optional": true,
|
| 378 |
+
"os": [
|
| 379 |
+
"android"
|
| 380 |
+
],
|
| 381 |
+
"engines": {
|
| 382 |
+
"node": ">=12"
|
| 383 |
+
}
|
| 384 |
+
},
|
| 385 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 386 |
+
"version": "0.21.5",
|
| 387 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 388 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 389 |
+
"cpu": [
|
| 390 |
+
"arm64"
|
| 391 |
+
],
|
| 392 |
+
"dev": true,
|
| 393 |
+
"license": "MIT",
|
| 394 |
+
"optional": true,
|
| 395 |
+
"os": [
|
| 396 |
+
"darwin"
|
| 397 |
+
],
|
| 398 |
+
"engines": {
|
| 399 |
+
"node": ">=12"
|
| 400 |
+
}
|
| 401 |
+
},
|
| 402 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 403 |
+
"version": "0.21.5",
|
| 404 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 405 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 406 |
+
"cpu": [
|
| 407 |
+
"x64"
|
| 408 |
+
],
|
| 409 |
+
"dev": true,
|
| 410 |
+
"license": "MIT",
|
| 411 |
+
"optional": true,
|
| 412 |
+
"os": [
|
| 413 |
+
"darwin"
|
| 414 |
+
],
|
| 415 |
+
"engines": {
|
| 416 |
+
"node": ">=12"
|
| 417 |
+
}
|
| 418 |
+
},
|
| 419 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 420 |
+
"version": "0.21.5",
|
| 421 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 422 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 423 |
+
"cpu": [
|
| 424 |
+
"arm64"
|
| 425 |
+
],
|
| 426 |
+
"dev": true,
|
| 427 |
+
"license": "MIT",
|
| 428 |
+
"optional": true,
|
| 429 |
+
"os": [
|
| 430 |
+
"freebsd"
|
| 431 |
+
],
|
| 432 |
+
"engines": {
|
| 433 |
+
"node": ">=12"
|
| 434 |
+
}
|
| 435 |
+
},
|
| 436 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 437 |
+
"version": "0.21.5",
|
| 438 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 439 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 440 |
+
"cpu": [
|
| 441 |
+
"x64"
|
| 442 |
+
],
|
| 443 |
+
"dev": true,
|
| 444 |
+
"license": "MIT",
|
| 445 |
+
"optional": true,
|
| 446 |
+
"os": [
|
| 447 |
+
"freebsd"
|
| 448 |
+
],
|
| 449 |
+
"engines": {
|
| 450 |
+
"node": ">=12"
|
| 451 |
+
}
|
| 452 |
+
},
|
| 453 |
+
"node_modules/@esbuild/linux-arm": {
|
| 454 |
+
"version": "0.21.5",
|
| 455 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 456 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 457 |
+
"cpu": [
|
| 458 |
+
"arm"
|
| 459 |
+
],
|
| 460 |
+
"dev": true,
|
| 461 |
+
"license": "MIT",
|
| 462 |
+
"optional": true,
|
| 463 |
+
"os": [
|
| 464 |
+
"linux"
|
| 465 |
+
],
|
| 466 |
+
"engines": {
|
| 467 |
+
"node": ">=12"
|
| 468 |
+
}
|
| 469 |
+
},
|
| 470 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 471 |
+
"version": "0.21.5",
|
| 472 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 473 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 474 |
+
"cpu": [
|
| 475 |
+
"arm64"
|
| 476 |
+
],
|
| 477 |
+
"dev": true,
|
| 478 |
+
"license": "MIT",
|
| 479 |
+
"optional": true,
|
| 480 |
+
"os": [
|
| 481 |
+
"linux"
|
| 482 |
+
],
|
| 483 |
+
"engines": {
|
| 484 |
+
"node": ">=12"
|
| 485 |
+
}
|
| 486 |
+
},
|
| 487 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 488 |
+
"version": "0.21.5",
|
| 489 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 490 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 491 |
+
"cpu": [
|
| 492 |
+
"ia32"
|
| 493 |
+
],
|
| 494 |
+
"dev": true,
|
| 495 |
+
"license": "MIT",
|
| 496 |
+
"optional": true,
|
| 497 |
+
"os": [
|
| 498 |
+
"linux"
|
| 499 |
+
],
|
| 500 |
+
"engines": {
|
| 501 |
+
"node": ">=12"
|
| 502 |
+
}
|
| 503 |
+
},
|
| 504 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 505 |
+
"version": "0.21.5",
|
| 506 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 507 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 508 |
+
"cpu": [
|
| 509 |
+
"loong64"
|
| 510 |
+
],
|
| 511 |
+
"dev": true,
|
| 512 |
+
"license": "MIT",
|
| 513 |
+
"optional": true,
|
| 514 |
+
"os": [
|
| 515 |
+
"linux"
|
| 516 |
+
],
|
| 517 |
+
"engines": {
|
| 518 |
+
"node": ">=12"
|
| 519 |
+
}
|
| 520 |
+
},
|
| 521 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 522 |
+
"version": "0.21.5",
|
| 523 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 524 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 525 |
+
"cpu": [
|
| 526 |
+
"mips64el"
|
| 527 |
+
],
|
| 528 |
+
"dev": true,
|
| 529 |
+
"license": "MIT",
|
| 530 |
+
"optional": true,
|
| 531 |
+
"os": [
|
| 532 |
+
"linux"
|
| 533 |
+
],
|
| 534 |
+
"engines": {
|
| 535 |
+
"node": ">=12"
|
| 536 |
+
}
|
| 537 |
+
},
|
| 538 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 539 |
+
"version": "0.21.5",
|
| 540 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 541 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 542 |
+
"cpu": [
|
| 543 |
+
"ppc64"
|
| 544 |
+
],
|
| 545 |
+
"dev": true,
|
| 546 |
+
"license": "MIT",
|
| 547 |
+
"optional": true,
|
| 548 |
+
"os": [
|
| 549 |
+
"linux"
|
| 550 |
+
],
|
| 551 |
+
"engines": {
|
| 552 |
+
"node": ">=12"
|
| 553 |
+
}
|
| 554 |
+
},
|
| 555 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 556 |
+
"version": "0.21.5",
|
| 557 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 558 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 559 |
+
"cpu": [
|
| 560 |
+
"riscv64"
|
| 561 |
+
],
|
| 562 |
+
"dev": true,
|
| 563 |
+
"license": "MIT",
|
| 564 |
+
"optional": true,
|
| 565 |
+
"os": [
|
| 566 |
+
"linux"
|
| 567 |
+
],
|
| 568 |
+
"engines": {
|
| 569 |
+
"node": ">=12"
|
| 570 |
+
}
|
| 571 |
+
},
|
| 572 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 573 |
+
"version": "0.21.5",
|
| 574 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 575 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 576 |
+
"cpu": [
|
| 577 |
+
"s390x"
|
| 578 |
+
],
|
| 579 |
+
"dev": true,
|
| 580 |
+
"license": "MIT",
|
| 581 |
+
"optional": true,
|
| 582 |
+
"os": [
|
| 583 |
+
"linux"
|
| 584 |
+
],
|
| 585 |
+
"engines": {
|
| 586 |
+
"node": ">=12"
|
| 587 |
+
}
|
| 588 |
+
},
|
| 589 |
+
"node_modules/@esbuild/linux-x64": {
|
| 590 |
+
"version": "0.21.5",
|
| 591 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 592 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 593 |
+
"cpu": [
|
| 594 |
+
"x64"
|
| 595 |
+
],
|
| 596 |
+
"dev": true,
|
| 597 |
+
"license": "MIT",
|
| 598 |
+
"optional": true,
|
| 599 |
+
"os": [
|
| 600 |
+
"linux"
|
| 601 |
+
],
|
| 602 |
+
"engines": {
|
| 603 |
+
"node": ">=12"
|
| 604 |
+
}
|
| 605 |
+
},
|
| 606 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 607 |
+
"version": "0.21.5",
|
| 608 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 609 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 610 |
+
"cpu": [
|
| 611 |
+
"x64"
|
| 612 |
+
],
|
| 613 |
+
"dev": true,
|
| 614 |
+
"license": "MIT",
|
| 615 |
+
"optional": true,
|
| 616 |
+
"os": [
|
| 617 |
+
"netbsd"
|
| 618 |
+
],
|
| 619 |
+
"engines": {
|
| 620 |
+
"node": ">=12"
|
| 621 |
+
}
|
| 622 |
+
},
|
| 623 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 624 |
+
"version": "0.21.5",
|
| 625 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 626 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 627 |
+
"cpu": [
|
| 628 |
+
"x64"
|
| 629 |
+
],
|
| 630 |
+
"dev": true,
|
| 631 |
+
"license": "MIT",
|
| 632 |
+
"optional": true,
|
| 633 |
+
"os": [
|
| 634 |
+
"openbsd"
|
| 635 |
+
],
|
| 636 |
+
"engines": {
|
| 637 |
+
"node": ">=12"
|
| 638 |
+
}
|
| 639 |
+
},
|
| 640 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 641 |
+
"version": "0.21.5",
|
| 642 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 643 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 644 |
+
"cpu": [
|
| 645 |
+
"x64"
|
| 646 |
+
],
|
| 647 |
+
"dev": true,
|
| 648 |
+
"license": "MIT",
|
| 649 |
+
"optional": true,
|
| 650 |
+
"os": [
|
| 651 |
+
"sunos"
|
| 652 |
+
],
|
| 653 |
+
"engines": {
|
| 654 |
+
"node": ">=12"
|
| 655 |
+
}
|
| 656 |
+
},
|
| 657 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 658 |
+
"version": "0.21.5",
|
| 659 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 660 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 661 |
+
"cpu": [
|
| 662 |
+
"arm64"
|
| 663 |
+
],
|
| 664 |
+
"dev": true,
|
| 665 |
+
"license": "MIT",
|
| 666 |
+
"optional": true,
|
| 667 |
+
"os": [
|
| 668 |
+
"win32"
|
| 669 |
+
],
|
| 670 |
+
"engines": {
|
| 671 |
+
"node": ">=12"
|
| 672 |
+
}
|
| 673 |
+
},
|
| 674 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 675 |
+
"version": "0.21.5",
|
| 676 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 677 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 678 |
+
"cpu": [
|
| 679 |
+
"ia32"
|
| 680 |
+
],
|
| 681 |
+
"dev": true,
|
| 682 |
+
"license": "MIT",
|
| 683 |
+
"optional": true,
|
| 684 |
+
"os": [
|
| 685 |
+
"win32"
|
| 686 |
+
],
|
| 687 |
+
"engines": {
|
| 688 |
+
"node": ">=12"
|
| 689 |
+
}
|
| 690 |
+
},
|
| 691 |
+
"node_modules/@esbuild/win32-x64": {
|
| 692 |
+
"version": "0.21.5",
|
| 693 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 694 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 695 |
+
"cpu": [
|
| 696 |
+
"x64"
|
| 697 |
+
],
|
| 698 |
+
"dev": true,
|
| 699 |
+
"license": "MIT",
|
| 700 |
+
"optional": true,
|
| 701 |
+
"os": [
|
| 702 |
+
"win32"
|
| 703 |
+
],
|
| 704 |
+
"engines": {
|
| 705 |
+
"node": ">=12"
|
| 706 |
+
}
|
| 707 |
+
},
|
| 708 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 709 |
+
"version": "0.3.13",
|
| 710 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 711 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 712 |
+
"dev": true,
|
| 713 |
+
"license": "MIT",
|
| 714 |
+
"dependencies": {
|
| 715 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 716 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 717 |
+
}
|
| 718 |
+
},
|
| 719 |
+
"node_modules/@jridgewell/remapping": {
|
| 720 |
+
"version": "2.3.5",
|
| 721 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 722 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 723 |
+
"dev": true,
|
| 724 |
+
"license": "MIT",
|
| 725 |
+
"dependencies": {
|
| 726 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 727 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 728 |
+
}
|
| 729 |
+
},
|
| 730 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 731 |
+
"version": "3.1.2",
|
| 732 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 733 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 734 |
+
"dev": true,
|
| 735 |
+
"license": "MIT",
|
| 736 |
+
"engines": {
|
| 737 |
+
"node": ">=6.0.0"
|
| 738 |
+
}
|
| 739 |
+
},
|
| 740 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 741 |
+
"version": "1.5.5",
|
| 742 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 743 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 744 |
+
"dev": true,
|
| 745 |
+
"license": "MIT"
|
| 746 |
+
},
|
| 747 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 748 |
+
"version": "0.3.31",
|
| 749 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 750 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 751 |
+
"dev": true,
|
| 752 |
+
"license": "MIT",
|
| 753 |
+
"dependencies": {
|
| 754 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 755 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 756 |
+
}
|
| 757 |
+
},
|
| 758 |
+
"node_modules/@remix-run/router": {
|
| 759 |
+
"version": "1.23.2",
|
| 760 |
+
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
| 761 |
+
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
| 762 |
+
"license": "MIT",
|
| 763 |
+
"engines": {
|
| 764 |
+
"node": ">=14.0.0"
|
| 765 |
+
}
|
| 766 |
+
},
|
| 767 |
+
"node_modules/@rolldown/pluginutils": {
|
| 768 |
+
"version": "1.0.0-beta.27",
|
| 769 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 770 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 771 |
+
"dev": true,
|
| 772 |
+
"license": "MIT"
|
| 773 |
+
},
|
| 774 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 775 |
+
"version": "4.60.2",
|
| 776 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
| 777 |
+
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
|
| 778 |
+
"cpu": [
|
| 779 |
+
"arm"
|
| 780 |
+
],
|
| 781 |
+
"dev": true,
|
| 782 |
+
"license": "MIT",
|
| 783 |
+
"optional": true,
|
| 784 |
+
"os": [
|
| 785 |
+
"android"
|
| 786 |
+
]
|
| 787 |
+
},
|
| 788 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 789 |
+
"version": "4.60.2",
|
| 790 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
|
| 791 |
+
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
|
| 792 |
+
"cpu": [
|
| 793 |
+
"arm64"
|
| 794 |
+
],
|
| 795 |
+
"dev": true,
|
| 796 |
+
"license": "MIT",
|
| 797 |
+
"optional": true,
|
| 798 |
+
"os": [
|
| 799 |
+
"android"
|
| 800 |
+
]
|
| 801 |
+
},
|
| 802 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 803 |
+
"version": "4.60.2",
|
| 804 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
| 805 |
+
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
| 806 |
+
"cpu": [
|
| 807 |
+
"arm64"
|
| 808 |
+
],
|
| 809 |
+
"dev": true,
|
| 810 |
+
"license": "MIT",
|
| 811 |
+
"optional": true,
|
| 812 |
+
"os": [
|
| 813 |
+
"darwin"
|
| 814 |
+
]
|
| 815 |
+
},
|
| 816 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 817 |
+
"version": "4.60.2",
|
| 818 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
|
| 819 |
+
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
|
| 820 |
+
"cpu": [
|
| 821 |
+
"x64"
|
| 822 |
+
],
|
| 823 |
+
"dev": true,
|
| 824 |
+
"license": "MIT",
|
| 825 |
+
"optional": true,
|
| 826 |
+
"os": [
|
| 827 |
+
"darwin"
|
| 828 |
+
]
|
| 829 |
+
},
|
| 830 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 831 |
+
"version": "4.60.2",
|
| 832 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
|
| 833 |
+
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
|
| 834 |
+
"cpu": [
|
| 835 |
+
"arm64"
|
| 836 |
+
],
|
| 837 |
+
"dev": true,
|
| 838 |
+
"license": "MIT",
|
| 839 |
+
"optional": true,
|
| 840 |
+
"os": [
|
| 841 |
+
"freebsd"
|
| 842 |
+
]
|
| 843 |
+
},
|
| 844 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 845 |
+
"version": "4.60.2",
|
| 846 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
|
| 847 |
+
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
|
| 848 |
+
"cpu": [
|
| 849 |
+
"x64"
|
| 850 |
+
],
|
| 851 |
+
"dev": true,
|
| 852 |
+
"license": "MIT",
|
| 853 |
+
"optional": true,
|
| 854 |
+
"os": [
|
| 855 |
+
"freebsd"
|
| 856 |
+
]
|
| 857 |
+
},
|
| 858 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 859 |
+
"version": "4.60.2",
|
| 860 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
|
| 861 |
+
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
|
| 862 |
+
"cpu": [
|
| 863 |
+
"arm"
|
| 864 |
+
],
|
| 865 |
+
"dev": true,
|
| 866 |
+
"license": "MIT",
|
| 867 |
+
"optional": true,
|
| 868 |
+
"os": [
|
| 869 |
+
"linux"
|
| 870 |
+
]
|
| 871 |
+
},
|
| 872 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 873 |
+
"version": "4.60.2",
|
| 874 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
|
| 875 |
+
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
|
| 876 |
+
"cpu": [
|
| 877 |
+
"arm"
|
| 878 |
+
],
|
| 879 |
+
"dev": true,
|
| 880 |
+
"license": "MIT",
|
| 881 |
+
"optional": true,
|
| 882 |
+
"os": [
|
| 883 |
+
"linux"
|
| 884 |
+
]
|
| 885 |
+
},
|
| 886 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 887 |
+
"version": "4.60.2",
|
| 888 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
|
| 889 |
+
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
|
| 890 |
+
"cpu": [
|
| 891 |
+
"arm64"
|
| 892 |
+
],
|
| 893 |
+
"dev": true,
|
| 894 |
+
"license": "MIT",
|
| 895 |
+
"optional": true,
|
| 896 |
+
"os": [
|
| 897 |
+
"linux"
|
| 898 |
+
]
|
| 899 |
+
},
|
| 900 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 901 |
+
"version": "4.60.2",
|
| 902 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
|
| 903 |
+
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
|
| 904 |
+
"cpu": [
|
| 905 |
+
"arm64"
|
| 906 |
+
],
|
| 907 |
+
"dev": true,
|
| 908 |
+
"license": "MIT",
|
| 909 |
+
"optional": true,
|
| 910 |
+
"os": [
|
| 911 |
+
"linux"
|
| 912 |
+
]
|
| 913 |
+
},
|
| 914 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 915 |
+
"version": "4.60.2",
|
| 916 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
|
| 917 |
+
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
|
| 918 |
+
"cpu": [
|
| 919 |
+
"loong64"
|
| 920 |
+
],
|
| 921 |
+
"dev": true,
|
| 922 |
+
"license": "MIT",
|
| 923 |
+
"optional": true,
|
| 924 |
+
"os": [
|
| 925 |
+
"linux"
|
| 926 |
+
]
|
| 927 |
+
},
|
| 928 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 929 |
+
"version": "4.60.2",
|
| 930 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
|
| 931 |
+
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
|
| 932 |
+
"cpu": [
|
| 933 |
+
"loong64"
|
| 934 |
+
],
|
| 935 |
+
"dev": true,
|
| 936 |
+
"license": "MIT",
|
| 937 |
+
"optional": true,
|
| 938 |
+
"os": [
|
| 939 |
+
"linux"
|
| 940 |
+
]
|
| 941 |
+
},
|
| 942 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 943 |
+
"version": "4.60.2",
|
| 944 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
|
| 945 |
+
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
|
| 946 |
+
"cpu": [
|
| 947 |
+
"ppc64"
|
| 948 |
+
],
|
| 949 |
+
"dev": true,
|
| 950 |
+
"license": "MIT",
|
| 951 |
+
"optional": true,
|
| 952 |
+
"os": [
|
| 953 |
+
"linux"
|
| 954 |
+
]
|
| 955 |
+
},
|
| 956 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 957 |
+
"version": "4.60.2",
|
| 958 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
|
| 959 |
+
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
|
| 960 |
+
"cpu": [
|
| 961 |
+
"ppc64"
|
| 962 |
+
],
|
| 963 |
+
"dev": true,
|
| 964 |
+
"license": "MIT",
|
| 965 |
+
"optional": true,
|
| 966 |
+
"os": [
|
| 967 |
+
"linux"
|
| 968 |
+
]
|
| 969 |
+
},
|
| 970 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 971 |
+
"version": "4.60.2",
|
| 972 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
|
| 973 |
+
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
|
| 974 |
+
"cpu": [
|
| 975 |
+
"riscv64"
|
| 976 |
+
],
|
| 977 |
+
"dev": true,
|
| 978 |
+
"license": "MIT",
|
| 979 |
+
"optional": true,
|
| 980 |
+
"os": [
|
| 981 |
+
"linux"
|
| 982 |
+
]
|
| 983 |
+
},
|
| 984 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 985 |
+
"version": "4.60.2",
|
| 986 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
|
| 987 |
+
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
|
| 988 |
+
"cpu": [
|
| 989 |
+
"riscv64"
|
| 990 |
+
],
|
| 991 |
+
"dev": true,
|
| 992 |
+
"license": "MIT",
|
| 993 |
+
"optional": true,
|
| 994 |
+
"os": [
|
| 995 |
+
"linux"
|
| 996 |
+
]
|
| 997 |
+
},
|
| 998 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 999 |
+
"version": "4.60.2",
|
| 1000 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
| 1001 |
+
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
| 1002 |
+
"cpu": [
|
| 1003 |
+
"s390x"
|
| 1004 |
+
],
|
| 1005 |
+
"dev": true,
|
| 1006 |
+
"license": "MIT",
|
| 1007 |
+
"optional": true,
|
| 1008 |
+
"os": [
|
| 1009 |
+
"linux"
|
| 1010 |
+
]
|
| 1011 |
+
},
|
| 1012 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 1013 |
+
"version": "4.60.2",
|
| 1014 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
| 1015 |
+
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
| 1016 |
+
"cpu": [
|
| 1017 |
+
"x64"
|
| 1018 |
+
],
|
| 1019 |
+
"dev": true,
|
| 1020 |
+
"license": "MIT",
|
| 1021 |
+
"optional": true,
|
| 1022 |
+
"os": [
|
| 1023 |
+
"linux"
|
| 1024 |
+
]
|
| 1025 |
+
},
|
| 1026 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1027 |
+
"version": "4.60.2",
|
| 1028 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
| 1029 |
+
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
| 1030 |
+
"cpu": [
|
| 1031 |
+
"x64"
|
| 1032 |
+
],
|
| 1033 |
+
"dev": true,
|
| 1034 |
+
"license": "MIT",
|
| 1035 |
+
"optional": true,
|
| 1036 |
+
"os": [
|
| 1037 |
+
"linux"
|
| 1038 |
+
]
|
| 1039 |
+
},
|
| 1040 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1041 |
+
"version": "4.60.2",
|
| 1042 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
| 1043 |
+
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
| 1044 |
+
"cpu": [
|
| 1045 |
+
"x64"
|
| 1046 |
+
],
|
| 1047 |
+
"dev": true,
|
| 1048 |
+
"license": "MIT",
|
| 1049 |
+
"optional": true,
|
| 1050 |
+
"os": [
|
| 1051 |
+
"openbsd"
|
| 1052 |
+
]
|
| 1053 |
+
},
|
| 1054 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1055 |
+
"version": "4.60.2",
|
| 1056 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
| 1057 |
+
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
| 1058 |
+
"cpu": [
|
| 1059 |
+
"arm64"
|
| 1060 |
+
],
|
| 1061 |
+
"dev": true,
|
| 1062 |
+
"license": "MIT",
|
| 1063 |
+
"optional": true,
|
| 1064 |
+
"os": [
|
| 1065 |
+
"openharmony"
|
| 1066 |
+
]
|
| 1067 |
+
},
|
| 1068 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1069 |
+
"version": "4.60.2",
|
| 1070 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
| 1071 |
+
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
| 1072 |
+
"cpu": [
|
| 1073 |
+
"arm64"
|
| 1074 |
+
],
|
| 1075 |
+
"dev": true,
|
| 1076 |
+
"license": "MIT",
|
| 1077 |
+
"optional": true,
|
| 1078 |
+
"os": [
|
| 1079 |
+
"win32"
|
| 1080 |
+
]
|
| 1081 |
+
},
|
| 1082 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1083 |
+
"version": "4.60.2",
|
| 1084 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
| 1085 |
+
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
| 1086 |
+
"cpu": [
|
| 1087 |
+
"ia32"
|
| 1088 |
+
],
|
| 1089 |
+
"dev": true,
|
| 1090 |
+
"license": "MIT",
|
| 1091 |
+
"optional": true,
|
| 1092 |
+
"os": [
|
| 1093 |
+
"win32"
|
| 1094 |
+
]
|
| 1095 |
+
},
|
| 1096 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1097 |
+
"version": "4.60.2",
|
| 1098 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
|
| 1099 |
+
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
|
| 1100 |
+
"cpu": [
|
| 1101 |
+
"x64"
|
| 1102 |
+
],
|
| 1103 |
+
"dev": true,
|
| 1104 |
+
"license": "MIT",
|
| 1105 |
+
"optional": true,
|
| 1106 |
+
"os": [
|
| 1107 |
+
"win32"
|
| 1108 |
+
]
|
| 1109 |
+
},
|
| 1110 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1111 |
+
"version": "4.60.2",
|
| 1112 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
|
| 1113 |
+
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
|
| 1114 |
+
"cpu": [
|
| 1115 |
+
"x64"
|
| 1116 |
+
],
|
| 1117 |
+
"dev": true,
|
| 1118 |
+
"license": "MIT",
|
| 1119 |
+
"optional": true,
|
| 1120 |
+
"os": [
|
| 1121 |
+
"win32"
|
| 1122 |
+
]
|
| 1123 |
+
},
|
| 1124 |
+
"node_modules/@thoughtspot/visual-embed-sdk": {
|
| 1125 |
+
"version": "1.47.3",
|
| 1126 |
+
"resolved": "https://registry.npmjs.org/@thoughtspot/visual-embed-sdk/-/visual-embed-sdk-1.47.3.tgz",
|
| 1127 |
+
"integrity": "sha512-PJ00KD6hmC3J83Z6iLnFIcpHg9zGFRXn1EP0Vrz/2peR7WSzZbhqLDEtIJs4Kd6Ly5yQuve7XNF4LBj6EtNclA==",
|
| 1128 |
+
"license": "ThoughtSpot Development Tools End User License Agreement",
|
| 1129 |
+
"dependencies": {
|
| 1130 |
+
"classnames": "^2.3.1",
|
| 1131 |
+
"eventemitter3": "^4.0.7",
|
| 1132 |
+
"lodash": "^4.18.1",
|
| 1133 |
+
"mixpanel-browser": "2.47.0",
|
| 1134 |
+
"ts-deepmerge": "^6.0.2",
|
| 1135 |
+
"tslib": "^2.5.3",
|
| 1136 |
+
"use-deep-compare-effect": "^1.8.1",
|
| 1137 |
+
"yaml": "^2.5.1"
|
| 1138 |
+
},
|
| 1139 |
+
"peerDependencies": {
|
| 1140 |
+
"react": "> 16.8.0",
|
| 1141 |
+
"react-dom": "> 16.8.0"
|
| 1142 |
+
}
|
| 1143 |
+
},
|
| 1144 |
+
"node_modules/@types/babel__core": {
|
| 1145 |
+
"version": "7.20.5",
|
| 1146 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1147 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1148 |
+
"dev": true,
|
| 1149 |
+
"license": "MIT",
|
| 1150 |
+
"dependencies": {
|
| 1151 |
+
"@babel/parser": "^7.20.7",
|
| 1152 |
+
"@babel/types": "^7.20.7",
|
| 1153 |
+
"@types/babel__generator": "*",
|
| 1154 |
+
"@types/babel__template": "*",
|
| 1155 |
+
"@types/babel__traverse": "*"
|
| 1156 |
+
}
|
| 1157 |
+
},
|
| 1158 |
+
"node_modules/@types/babel__generator": {
|
| 1159 |
+
"version": "7.27.0",
|
| 1160 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1161 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1162 |
+
"dev": true,
|
| 1163 |
+
"license": "MIT",
|
| 1164 |
+
"dependencies": {
|
| 1165 |
+
"@babel/types": "^7.0.0"
|
| 1166 |
+
}
|
| 1167 |
+
},
|
| 1168 |
+
"node_modules/@types/babel__template": {
|
| 1169 |
+
"version": "7.4.4",
|
| 1170 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1171 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1172 |
+
"dev": true,
|
| 1173 |
+
"license": "MIT",
|
| 1174 |
+
"dependencies": {
|
| 1175 |
+
"@babel/parser": "^7.1.0",
|
| 1176 |
+
"@babel/types": "^7.0.0"
|
| 1177 |
+
}
|
| 1178 |
+
},
|
| 1179 |
+
"node_modules/@types/babel__traverse": {
|
| 1180 |
+
"version": "7.28.0",
|
| 1181 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1182 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1183 |
+
"dev": true,
|
| 1184 |
+
"license": "MIT",
|
| 1185 |
+
"dependencies": {
|
| 1186 |
+
"@babel/types": "^7.28.2"
|
| 1187 |
+
}
|
| 1188 |
+
},
|
| 1189 |
+
"node_modules/@types/estree": {
|
| 1190 |
+
"version": "1.0.8",
|
| 1191 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 1192 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 1193 |
+
"dev": true,
|
| 1194 |
+
"license": "MIT"
|
| 1195 |
+
},
|
| 1196 |
+
"node_modules/@types/prop-types": {
|
| 1197 |
+
"version": "15.7.15",
|
| 1198 |
+
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
| 1199 |
+
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
| 1200 |
+
"dev": true,
|
| 1201 |
+
"license": "MIT"
|
| 1202 |
+
},
|
| 1203 |
+
"node_modules/@types/react": {
|
| 1204 |
+
"version": "18.3.28",
|
| 1205 |
+
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
| 1206 |
+
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
| 1207 |
+
"dev": true,
|
| 1208 |
+
"license": "MIT",
|
| 1209 |
+
"dependencies": {
|
| 1210 |
+
"@types/prop-types": "*",
|
| 1211 |
+
"csstype": "^3.2.2"
|
| 1212 |
+
}
|
| 1213 |
+
},
|
| 1214 |
+
"node_modules/@types/react-dom": {
|
| 1215 |
+
"version": "18.3.7",
|
| 1216 |
+
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
| 1217 |
+
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
| 1218 |
+
"dev": true,
|
| 1219 |
+
"license": "MIT",
|
| 1220 |
+
"peerDependencies": {
|
| 1221 |
+
"@types/react": "^18.0.0"
|
| 1222 |
+
}
|
| 1223 |
+
},
|
| 1224 |
+
"node_modules/@vercel/analytics": {
|
| 1225 |
+
"version": "2.0.1",
|
| 1226 |
+
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
|
| 1227 |
+
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
|
| 1228 |
+
"license": "MIT",
|
| 1229 |
+
"peerDependencies": {
|
| 1230 |
+
"@remix-run/react": "^2",
|
| 1231 |
+
"@sveltejs/kit": "^1 || ^2",
|
| 1232 |
+
"next": ">= 13",
|
| 1233 |
+
"nuxt": ">= 3",
|
| 1234 |
+
"react": "^18 || ^19 || ^19.0.0-rc",
|
| 1235 |
+
"svelte": ">= 4",
|
| 1236 |
+
"vue": "^3",
|
| 1237 |
+
"vue-router": "^4"
|
| 1238 |
+
},
|
| 1239 |
+
"peerDependenciesMeta": {
|
| 1240 |
+
"@remix-run/react": {
|
| 1241 |
+
"optional": true
|
| 1242 |
+
},
|
| 1243 |
+
"@sveltejs/kit": {
|
| 1244 |
+
"optional": true
|
| 1245 |
+
},
|
| 1246 |
+
"next": {
|
| 1247 |
+
"optional": true
|
| 1248 |
+
},
|
| 1249 |
+
"nuxt": {
|
| 1250 |
+
"optional": true
|
| 1251 |
+
},
|
| 1252 |
+
"react": {
|
| 1253 |
+
"optional": true
|
| 1254 |
+
},
|
| 1255 |
+
"svelte": {
|
| 1256 |
+
"optional": true
|
| 1257 |
+
},
|
| 1258 |
+
"vue": {
|
| 1259 |
+
"optional": true
|
| 1260 |
+
},
|
| 1261 |
+
"vue-router": {
|
| 1262 |
+
"optional": true
|
| 1263 |
+
}
|
| 1264 |
+
}
|
| 1265 |
+
},
|
| 1266 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1267 |
+
"version": "4.7.0",
|
| 1268 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1269 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1270 |
+
"dev": true,
|
| 1271 |
+
"license": "MIT",
|
| 1272 |
+
"dependencies": {
|
| 1273 |
+
"@babel/core": "^7.28.0",
|
| 1274 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1275 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1276 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1277 |
+
"@types/babel__core": "^7.20.5",
|
| 1278 |
+
"react-refresh": "^0.17.0"
|
| 1279 |
+
},
|
| 1280 |
+
"engines": {
|
| 1281 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1282 |
+
},
|
| 1283 |
+
"peerDependencies": {
|
| 1284 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1285 |
+
}
|
| 1286 |
+
},
|
| 1287 |
+
"node_modules/baseline-browser-mapping": {
|
| 1288 |
+
"version": "2.10.24",
|
| 1289 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
|
| 1290 |
+
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
|
| 1291 |
+
"dev": true,
|
| 1292 |
+
"license": "Apache-2.0",
|
| 1293 |
+
"bin": {
|
| 1294 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1295 |
+
},
|
| 1296 |
+
"engines": {
|
| 1297 |
+
"node": ">=6.0.0"
|
| 1298 |
+
}
|
| 1299 |
+
},
|
| 1300 |
+
"node_modules/browserslist": {
|
| 1301 |
+
"version": "4.28.2",
|
| 1302 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
| 1303 |
+
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
| 1304 |
+
"dev": true,
|
| 1305 |
+
"funding": [
|
| 1306 |
+
{
|
| 1307 |
+
"type": "opencollective",
|
| 1308 |
+
"url": "https://opencollective.com/browserslist"
|
| 1309 |
+
},
|
| 1310 |
+
{
|
| 1311 |
+
"type": "tidelift",
|
| 1312 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1313 |
+
},
|
| 1314 |
+
{
|
| 1315 |
+
"type": "github",
|
| 1316 |
+
"url": "https://github.com/sponsors/ai"
|
| 1317 |
+
}
|
| 1318 |
+
],
|
| 1319 |
+
"license": "MIT",
|
| 1320 |
+
"dependencies": {
|
| 1321 |
+
"baseline-browser-mapping": "^2.10.12",
|
| 1322 |
+
"caniuse-lite": "^1.0.30001782",
|
| 1323 |
+
"electron-to-chromium": "^1.5.328",
|
| 1324 |
+
"node-releases": "^2.0.36",
|
| 1325 |
+
"update-browserslist-db": "^1.2.3"
|
| 1326 |
+
},
|
| 1327 |
+
"bin": {
|
| 1328 |
+
"browserslist": "cli.js"
|
| 1329 |
+
},
|
| 1330 |
+
"engines": {
|
| 1331 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1332 |
+
}
|
| 1333 |
+
},
|
| 1334 |
+
"node_modules/caniuse-lite": {
|
| 1335 |
+
"version": "1.0.30001791",
|
| 1336 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
| 1337 |
+
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
| 1338 |
+
"dev": true,
|
| 1339 |
+
"funding": [
|
| 1340 |
+
{
|
| 1341 |
+
"type": "opencollective",
|
| 1342 |
+
"url": "https://opencollective.com/browserslist"
|
| 1343 |
+
},
|
| 1344 |
+
{
|
| 1345 |
+
"type": "tidelift",
|
| 1346 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1347 |
+
},
|
| 1348 |
+
{
|
| 1349 |
+
"type": "github",
|
| 1350 |
+
"url": "https://github.com/sponsors/ai"
|
| 1351 |
+
}
|
| 1352 |
+
],
|
| 1353 |
+
"license": "CC-BY-4.0"
|
| 1354 |
+
},
|
| 1355 |
+
"node_modules/classnames": {
|
| 1356 |
+
"version": "2.5.1",
|
| 1357 |
+
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
| 1358 |
+
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
| 1359 |
+
"license": "MIT"
|
| 1360 |
+
},
|
| 1361 |
+
"node_modules/convert-source-map": {
|
| 1362 |
+
"version": "2.0.0",
|
| 1363 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1364 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1365 |
+
"dev": true,
|
| 1366 |
+
"license": "MIT"
|
| 1367 |
+
},
|
| 1368 |
+
"node_modules/csstype": {
|
| 1369 |
+
"version": "3.2.3",
|
| 1370 |
+
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 1371 |
+
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 1372 |
+
"dev": true,
|
| 1373 |
+
"license": "MIT"
|
| 1374 |
+
},
|
| 1375 |
+
"node_modules/debug": {
|
| 1376 |
+
"version": "4.4.3",
|
| 1377 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1378 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1379 |
+
"dev": true,
|
| 1380 |
+
"license": "MIT",
|
| 1381 |
+
"dependencies": {
|
| 1382 |
+
"ms": "^2.1.3"
|
| 1383 |
+
},
|
| 1384 |
+
"engines": {
|
| 1385 |
+
"node": ">=6.0"
|
| 1386 |
+
},
|
| 1387 |
+
"peerDependenciesMeta": {
|
| 1388 |
+
"supports-color": {
|
| 1389 |
+
"optional": true
|
| 1390 |
+
}
|
| 1391 |
+
}
|
| 1392 |
+
},
|
| 1393 |
+
"node_modules/dequal": {
|
| 1394 |
+
"version": "2.0.3",
|
| 1395 |
+
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
| 1396 |
+
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
| 1397 |
+
"license": "MIT",
|
| 1398 |
+
"engines": {
|
| 1399 |
+
"node": ">=6"
|
| 1400 |
+
}
|
| 1401 |
+
},
|
| 1402 |
+
"node_modules/electron-to-chromium": {
|
| 1403 |
+
"version": "1.5.344",
|
| 1404 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
|
| 1405 |
+
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
|
| 1406 |
+
"dev": true,
|
| 1407 |
+
"license": "ISC"
|
| 1408 |
+
},
|
| 1409 |
+
"node_modules/esbuild": {
|
| 1410 |
+
"version": "0.21.5",
|
| 1411 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1412 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1413 |
+
"dev": true,
|
| 1414 |
+
"hasInstallScript": true,
|
| 1415 |
+
"license": "MIT",
|
| 1416 |
+
"bin": {
|
| 1417 |
+
"esbuild": "bin/esbuild"
|
| 1418 |
+
},
|
| 1419 |
+
"engines": {
|
| 1420 |
+
"node": ">=12"
|
| 1421 |
+
},
|
| 1422 |
+
"optionalDependencies": {
|
| 1423 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1424 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1425 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1426 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1427 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1428 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1429 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1430 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1431 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1432 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1433 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1434 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1435 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1436 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1437 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1438 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1439 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1440 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1441 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1442 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1443 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1444 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1445 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1446 |
+
}
|
| 1447 |
+
},
|
| 1448 |
+
"node_modules/escalade": {
|
| 1449 |
+
"version": "3.2.0",
|
| 1450 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1451 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1452 |
+
"dev": true,
|
| 1453 |
+
"license": "MIT",
|
| 1454 |
+
"engines": {
|
| 1455 |
+
"node": ">=6"
|
| 1456 |
+
}
|
| 1457 |
+
},
|
| 1458 |
+
"node_modules/eventemitter3": {
|
| 1459 |
+
"version": "4.0.7",
|
| 1460 |
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
| 1461 |
+
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
| 1462 |
+
"license": "MIT"
|
| 1463 |
+
},
|
| 1464 |
+
"node_modules/fsevents": {
|
| 1465 |
+
"version": "2.3.3",
|
| 1466 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1467 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1468 |
+
"dev": true,
|
| 1469 |
+
"hasInstallScript": true,
|
| 1470 |
+
"license": "MIT",
|
| 1471 |
+
"optional": true,
|
| 1472 |
+
"os": [
|
| 1473 |
+
"darwin"
|
| 1474 |
+
],
|
| 1475 |
+
"engines": {
|
| 1476 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1477 |
+
}
|
| 1478 |
+
},
|
| 1479 |
+
"node_modules/gensync": {
|
| 1480 |
+
"version": "1.0.0-beta.2",
|
| 1481 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1482 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1483 |
+
"dev": true,
|
| 1484 |
+
"license": "MIT",
|
| 1485 |
+
"engines": {
|
| 1486 |
+
"node": ">=6.9.0"
|
| 1487 |
+
}
|
| 1488 |
+
},
|
| 1489 |
+
"node_modules/js-tokens": {
|
| 1490 |
+
"version": "4.0.0",
|
| 1491 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1492 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1493 |
+
"license": "MIT"
|
| 1494 |
+
},
|
| 1495 |
+
"node_modules/jsesc": {
|
| 1496 |
+
"version": "3.1.0",
|
| 1497 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1498 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1499 |
+
"dev": true,
|
| 1500 |
+
"license": "MIT",
|
| 1501 |
+
"bin": {
|
| 1502 |
+
"jsesc": "bin/jsesc"
|
| 1503 |
+
},
|
| 1504 |
+
"engines": {
|
| 1505 |
+
"node": ">=6"
|
| 1506 |
+
}
|
| 1507 |
+
},
|
| 1508 |
+
"node_modules/json5": {
|
| 1509 |
+
"version": "2.2.3",
|
| 1510 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1511 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1512 |
+
"dev": true,
|
| 1513 |
+
"license": "MIT",
|
| 1514 |
+
"bin": {
|
| 1515 |
+
"json5": "lib/cli.js"
|
| 1516 |
+
},
|
| 1517 |
+
"engines": {
|
| 1518 |
+
"node": ">=6"
|
| 1519 |
+
}
|
| 1520 |
+
},
|
| 1521 |
+
"node_modules/lodash": {
|
| 1522 |
+
"version": "4.18.1",
|
| 1523 |
+
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
| 1524 |
+
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
| 1525 |
+
"license": "MIT"
|
| 1526 |
+
},
|
| 1527 |
+
"node_modules/loose-envify": {
|
| 1528 |
+
"version": "1.4.0",
|
| 1529 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 1530 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 1531 |
+
"license": "MIT",
|
| 1532 |
+
"dependencies": {
|
| 1533 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 1534 |
+
},
|
| 1535 |
+
"bin": {
|
| 1536 |
+
"loose-envify": "cli.js"
|
| 1537 |
+
}
|
| 1538 |
+
},
|
| 1539 |
+
"node_modules/lru-cache": {
|
| 1540 |
+
"version": "5.1.1",
|
| 1541 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1542 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1543 |
+
"dev": true,
|
| 1544 |
+
"license": "ISC",
|
| 1545 |
+
"dependencies": {
|
| 1546 |
+
"yallist": "^3.0.2"
|
| 1547 |
+
}
|
| 1548 |
+
},
|
| 1549 |
+
"node_modules/lucide-react": {
|
| 1550 |
+
"version": "0.300.0",
|
| 1551 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.300.0.tgz",
|
| 1552 |
+
"integrity": "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg==",
|
| 1553 |
+
"license": "ISC",
|
| 1554 |
+
"peerDependencies": {
|
| 1555 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
| 1556 |
+
}
|
| 1557 |
+
},
|
| 1558 |
+
"node_modules/mixpanel-browser": {
|
| 1559 |
+
"version": "2.47.0",
|
| 1560 |
+
"resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.47.0.tgz",
|
| 1561 |
+
"integrity": "sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==",
|
| 1562 |
+
"license": "Apache-2.0"
|
| 1563 |
+
},
|
| 1564 |
+
"node_modules/ms": {
|
| 1565 |
+
"version": "2.1.3",
|
| 1566 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1567 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1568 |
+
"dev": true,
|
| 1569 |
+
"license": "MIT"
|
| 1570 |
+
},
|
| 1571 |
+
"node_modules/nanoid": {
|
| 1572 |
+
"version": "3.3.11",
|
| 1573 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 1574 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
| 1575 |
+
"dev": true,
|
| 1576 |
+
"funding": [
|
| 1577 |
+
{
|
| 1578 |
+
"type": "github",
|
| 1579 |
+
"url": "https://github.com/sponsors/ai"
|
| 1580 |
+
}
|
| 1581 |
+
],
|
| 1582 |
+
"license": "MIT",
|
| 1583 |
+
"bin": {
|
| 1584 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1585 |
+
},
|
| 1586 |
+
"engines": {
|
| 1587 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1588 |
+
}
|
| 1589 |
+
},
|
| 1590 |
+
"node_modules/node-releases": {
|
| 1591 |
+
"version": "2.0.38",
|
| 1592 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
| 1593 |
+
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
| 1594 |
+
"dev": true,
|
| 1595 |
+
"license": "MIT"
|
| 1596 |
+
},
|
| 1597 |
+
"node_modules/picocolors": {
|
| 1598 |
+
"version": "1.1.1",
|
| 1599 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1600 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1601 |
+
"dev": true,
|
| 1602 |
+
"license": "ISC"
|
| 1603 |
+
},
|
| 1604 |
+
"node_modules/postcss": {
|
| 1605 |
+
"version": "8.5.12",
|
| 1606 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
| 1607 |
+
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
| 1608 |
+
"dev": true,
|
| 1609 |
+
"funding": [
|
| 1610 |
+
{
|
| 1611 |
+
"type": "opencollective",
|
| 1612 |
+
"url": "https://opencollective.com/postcss/"
|
| 1613 |
+
},
|
| 1614 |
+
{
|
| 1615 |
+
"type": "tidelift",
|
| 1616 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1617 |
+
},
|
| 1618 |
+
{
|
| 1619 |
+
"type": "github",
|
| 1620 |
+
"url": "https://github.com/sponsors/ai"
|
| 1621 |
+
}
|
| 1622 |
+
],
|
| 1623 |
+
"license": "MIT",
|
| 1624 |
+
"dependencies": {
|
| 1625 |
+
"nanoid": "^3.3.11",
|
| 1626 |
+
"picocolors": "^1.1.1",
|
| 1627 |
+
"source-map-js": "^1.2.1"
|
| 1628 |
+
},
|
| 1629 |
+
"engines": {
|
| 1630 |
+
"node": "^10 || ^12 || >=14"
|
| 1631 |
+
}
|
| 1632 |
+
},
|
| 1633 |
+
"node_modules/react": {
|
| 1634 |
+
"version": "18.3.1",
|
| 1635 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1636 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1637 |
+
"license": "MIT",
|
| 1638 |
+
"dependencies": {
|
| 1639 |
+
"loose-envify": "^1.1.0"
|
| 1640 |
+
},
|
| 1641 |
+
"engines": {
|
| 1642 |
+
"node": ">=0.10.0"
|
| 1643 |
+
}
|
| 1644 |
+
},
|
| 1645 |
+
"node_modules/react-dom": {
|
| 1646 |
+
"version": "18.3.1",
|
| 1647 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 1648 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 1649 |
+
"license": "MIT",
|
| 1650 |
+
"dependencies": {
|
| 1651 |
+
"loose-envify": "^1.1.0",
|
| 1652 |
+
"scheduler": "^0.23.2"
|
| 1653 |
+
},
|
| 1654 |
+
"peerDependencies": {
|
| 1655 |
+
"react": "^18.3.1"
|
| 1656 |
+
}
|
| 1657 |
+
},
|
| 1658 |
+
"node_modules/react-refresh": {
|
| 1659 |
+
"version": "0.17.0",
|
| 1660 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 1661 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 1662 |
+
"dev": true,
|
| 1663 |
+
"license": "MIT",
|
| 1664 |
+
"engines": {
|
| 1665 |
+
"node": ">=0.10.0"
|
| 1666 |
+
}
|
| 1667 |
+
},
|
| 1668 |
+
"node_modules/react-router": {
|
| 1669 |
+
"version": "6.30.3",
|
| 1670 |
+
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
| 1671 |
+
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
| 1672 |
+
"license": "MIT",
|
| 1673 |
+
"dependencies": {
|
| 1674 |
+
"@remix-run/router": "1.23.2"
|
| 1675 |
+
},
|
| 1676 |
+
"engines": {
|
| 1677 |
+
"node": ">=14.0.0"
|
| 1678 |
+
},
|
| 1679 |
+
"peerDependencies": {
|
| 1680 |
+
"react": ">=16.8"
|
| 1681 |
+
}
|
| 1682 |
+
},
|
| 1683 |
+
"node_modules/react-router-dom": {
|
| 1684 |
+
"version": "6.30.3",
|
| 1685 |
+
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
| 1686 |
+
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
| 1687 |
+
"license": "MIT",
|
| 1688 |
+
"dependencies": {
|
| 1689 |
+
"@remix-run/router": "1.23.2",
|
| 1690 |
+
"react-router": "6.30.3"
|
| 1691 |
+
},
|
| 1692 |
+
"engines": {
|
| 1693 |
+
"node": ">=14.0.0"
|
| 1694 |
+
},
|
| 1695 |
+
"peerDependencies": {
|
| 1696 |
+
"react": ">=16.8",
|
| 1697 |
+
"react-dom": ">=16.8"
|
| 1698 |
+
}
|
| 1699 |
+
},
|
| 1700 |
+
"node_modules/rollup": {
|
| 1701 |
+
"version": "4.60.2",
|
| 1702 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
| 1703 |
+
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
| 1704 |
+
"dev": true,
|
| 1705 |
+
"license": "MIT",
|
| 1706 |
+
"dependencies": {
|
| 1707 |
+
"@types/estree": "1.0.8"
|
| 1708 |
+
},
|
| 1709 |
+
"bin": {
|
| 1710 |
+
"rollup": "dist/bin/rollup"
|
| 1711 |
+
},
|
| 1712 |
+
"engines": {
|
| 1713 |
+
"node": ">=18.0.0",
|
| 1714 |
+
"npm": ">=8.0.0"
|
| 1715 |
+
},
|
| 1716 |
+
"optionalDependencies": {
|
| 1717 |
+
"@rollup/rollup-android-arm-eabi": "4.60.2",
|
| 1718 |
+
"@rollup/rollup-android-arm64": "4.60.2",
|
| 1719 |
+
"@rollup/rollup-darwin-arm64": "4.60.2",
|
| 1720 |
+
"@rollup/rollup-darwin-x64": "4.60.2",
|
| 1721 |
+
"@rollup/rollup-freebsd-arm64": "4.60.2",
|
| 1722 |
+
"@rollup/rollup-freebsd-x64": "4.60.2",
|
| 1723 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
|
| 1724 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
|
| 1725 |
+
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
|
| 1726 |
+
"@rollup/rollup-linux-arm64-musl": "4.60.2",
|
| 1727 |
+
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
|
| 1728 |
+
"@rollup/rollup-linux-loong64-musl": "4.60.2",
|
| 1729 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
|
| 1730 |
+
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
|
| 1731 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
|
| 1732 |
+
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
|
| 1733 |
+
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
|
| 1734 |
+
"@rollup/rollup-linux-x64-gnu": "4.60.2",
|
| 1735 |
+
"@rollup/rollup-linux-x64-musl": "4.60.2",
|
| 1736 |
+
"@rollup/rollup-openbsd-x64": "4.60.2",
|
| 1737 |
+
"@rollup/rollup-openharmony-arm64": "4.60.2",
|
| 1738 |
+
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
|
| 1739 |
+
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
|
| 1740 |
+
"@rollup/rollup-win32-x64-gnu": "4.60.2",
|
| 1741 |
+
"@rollup/rollup-win32-x64-msvc": "4.60.2",
|
| 1742 |
+
"fsevents": "~2.3.2"
|
| 1743 |
+
}
|
| 1744 |
+
},
|
| 1745 |
+
"node_modules/scheduler": {
|
| 1746 |
+
"version": "0.23.2",
|
| 1747 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 1748 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 1749 |
+
"license": "MIT",
|
| 1750 |
+
"dependencies": {
|
| 1751 |
+
"loose-envify": "^1.1.0"
|
| 1752 |
+
}
|
| 1753 |
+
},
|
| 1754 |
+
"node_modules/semver": {
|
| 1755 |
+
"version": "6.3.1",
|
| 1756 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 1757 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 1758 |
+
"dev": true,
|
| 1759 |
+
"license": "ISC",
|
| 1760 |
+
"bin": {
|
| 1761 |
+
"semver": "bin/semver.js"
|
| 1762 |
+
}
|
| 1763 |
+
},
|
| 1764 |
+
"node_modules/source-map-js": {
|
| 1765 |
+
"version": "1.2.1",
|
| 1766 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1767 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1768 |
+
"dev": true,
|
| 1769 |
+
"license": "BSD-3-Clause",
|
| 1770 |
+
"engines": {
|
| 1771 |
+
"node": ">=0.10.0"
|
| 1772 |
+
}
|
| 1773 |
+
},
|
| 1774 |
+
"node_modules/ts-deepmerge": {
|
| 1775 |
+
"version": "6.2.1",
|
| 1776 |
+
"resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.1.tgz",
|
| 1777 |
+
"integrity": "sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==",
|
| 1778 |
+
"license": "ISC",
|
| 1779 |
+
"engines": {
|
| 1780 |
+
"node": ">=14.13.1"
|
| 1781 |
+
}
|
| 1782 |
+
},
|
| 1783 |
+
"node_modules/tslib": {
|
| 1784 |
+
"version": "2.8.1",
|
| 1785 |
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 1786 |
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 1787 |
+
"license": "0BSD"
|
| 1788 |
+
},
|
| 1789 |
+
"node_modules/typescript": {
|
| 1790 |
+
"version": "5.9.3",
|
| 1791 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 1792 |
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 1793 |
+
"dev": true,
|
| 1794 |
+
"license": "Apache-2.0",
|
| 1795 |
+
"bin": {
|
| 1796 |
+
"tsc": "bin/tsc",
|
| 1797 |
+
"tsserver": "bin/tsserver"
|
| 1798 |
+
},
|
| 1799 |
+
"engines": {
|
| 1800 |
+
"node": ">=14.17"
|
| 1801 |
+
}
|
| 1802 |
+
},
|
| 1803 |
+
"node_modules/update-browserslist-db": {
|
| 1804 |
+
"version": "1.2.3",
|
| 1805 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 1806 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 1807 |
+
"dev": true,
|
| 1808 |
+
"funding": [
|
| 1809 |
+
{
|
| 1810 |
+
"type": "opencollective",
|
| 1811 |
+
"url": "https://opencollective.com/browserslist"
|
| 1812 |
+
},
|
| 1813 |
+
{
|
| 1814 |
+
"type": "tidelift",
|
| 1815 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1816 |
+
},
|
| 1817 |
+
{
|
| 1818 |
+
"type": "github",
|
| 1819 |
+
"url": "https://github.com/sponsors/ai"
|
| 1820 |
+
}
|
| 1821 |
+
],
|
| 1822 |
+
"license": "MIT",
|
| 1823 |
+
"dependencies": {
|
| 1824 |
+
"escalade": "^3.2.0",
|
| 1825 |
+
"picocolors": "^1.1.1"
|
| 1826 |
+
},
|
| 1827 |
+
"bin": {
|
| 1828 |
+
"update-browserslist-db": "cli.js"
|
| 1829 |
+
},
|
| 1830 |
+
"peerDependencies": {
|
| 1831 |
+
"browserslist": ">= 4.21.0"
|
| 1832 |
+
}
|
| 1833 |
+
},
|
| 1834 |
+
"node_modules/use-deep-compare-effect": {
|
| 1835 |
+
"version": "1.8.1",
|
| 1836 |
+
"resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz",
|
| 1837 |
+
"integrity": "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==",
|
| 1838 |
+
"license": "MIT",
|
| 1839 |
+
"dependencies": {
|
| 1840 |
+
"@babel/runtime": "^7.12.5",
|
| 1841 |
+
"dequal": "^2.0.2"
|
| 1842 |
+
},
|
| 1843 |
+
"engines": {
|
| 1844 |
+
"node": ">=10",
|
| 1845 |
+
"npm": ">=6"
|
| 1846 |
+
},
|
| 1847 |
+
"peerDependencies": {
|
| 1848 |
+
"react": ">=16.13"
|
| 1849 |
+
}
|
| 1850 |
+
},
|
| 1851 |
+
"node_modules/vite": {
|
| 1852 |
+
"version": "5.4.21",
|
| 1853 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1854 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1855 |
+
"dev": true,
|
| 1856 |
+
"license": "MIT",
|
| 1857 |
+
"dependencies": {
|
| 1858 |
+
"esbuild": "^0.21.3",
|
| 1859 |
+
"postcss": "^8.4.43",
|
| 1860 |
+
"rollup": "^4.20.0"
|
| 1861 |
+
},
|
| 1862 |
+
"bin": {
|
| 1863 |
+
"vite": "bin/vite.js"
|
| 1864 |
+
},
|
| 1865 |
+
"engines": {
|
| 1866 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1867 |
+
},
|
| 1868 |
+
"funding": {
|
| 1869 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1870 |
+
},
|
| 1871 |
+
"optionalDependencies": {
|
| 1872 |
+
"fsevents": "~2.3.3"
|
| 1873 |
+
},
|
| 1874 |
+
"peerDependencies": {
|
| 1875 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1876 |
+
"less": "*",
|
| 1877 |
+
"lightningcss": "^1.21.0",
|
| 1878 |
+
"sass": "*",
|
| 1879 |
+
"sass-embedded": "*",
|
| 1880 |
+
"stylus": "*",
|
| 1881 |
+
"sugarss": "*",
|
| 1882 |
+
"terser": "^5.4.0"
|
| 1883 |
+
},
|
| 1884 |
+
"peerDependenciesMeta": {
|
| 1885 |
+
"@types/node": {
|
| 1886 |
+
"optional": true
|
| 1887 |
+
},
|
| 1888 |
+
"less": {
|
| 1889 |
+
"optional": true
|
| 1890 |
+
},
|
| 1891 |
+
"lightningcss": {
|
| 1892 |
+
"optional": true
|
| 1893 |
+
},
|
| 1894 |
+
"sass": {
|
| 1895 |
+
"optional": true
|
| 1896 |
+
},
|
| 1897 |
+
"sass-embedded": {
|
| 1898 |
+
"optional": true
|
| 1899 |
+
},
|
| 1900 |
+
"stylus": {
|
| 1901 |
+
"optional": true
|
| 1902 |
+
},
|
| 1903 |
+
"sugarss": {
|
| 1904 |
+
"optional": true
|
| 1905 |
+
},
|
| 1906 |
+
"terser": {
|
| 1907 |
+
"optional": true
|
| 1908 |
+
}
|
| 1909 |
+
}
|
| 1910 |
+
},
|
| 1911 |
+
"node_modules/yallist": {
|
| 1912 |
+
"version": "3.1.1",
|
| 1913 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 1914 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 1915 |
+
"dev": true,
|
| 1916 |
+
"license": "ISC"
|
| 1917 |
+
},
|
| 1918 |
+
"node_modules/yaml": {
|
| 1919 |
+
"version": "2.8.3",
|
| 1920 |
+
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
| 1921 |
+
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
| 1922 |
+
"license": "ISC",
|
| 1923 |
+
"bin": {
|
| 1924 |
+
"yaml": "bin.mjs"
|
| 1925 |
+
},
|
| 1926 |
+
"engines": {
|
| 1927 |
+
"node": ">= 14.6"
|
| 1928 |
+
},
|
| 1929 |
+
"funding": {
|
| 1930 |
+
"url": "https://github.com/sponsors/eemeli"
|
| 1931 |
+
}
|
| 1932 |
+
}
|
| 1933 |
+
}
|
| 1934 |
+
}
|
demoprep_app/portal/template-tse/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "northwind-thoughtspot-portal",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@thoughtspot/visual-embed-sdk": "^1.37.0",
|
| 13 |
+
"@vercel/analytics": "^2.0.1",
|
| 14 |
+
"lucide-react": "^0.300.0",
|
| 15 |
+
"react": "^18.2.0",
|
| 16 |
+
"react-dom": "^18.2.0",
|
| 17 |
+
"react-router-dom": "^6.21.0"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@types/react": "^18.2.45",
|
| 21 |
+
"@types/react-dom": "^18.2.18",
|
| 22 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 23 |
+
"typescript": "^5.3.3",
|
| 24 |
+
"vite": "^5.0.10"
|
| 25 |
+
}
|
| 26 |
+
}
|
demoprep_app/portal/template-tse/public/northwind-icon.svg
ADDED
|
|
demoprep_app/portal/template-tse/public/skyscanner-icon.svg
ADDED
|
|
demoprep_app/portal/template-tse/src/App.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
|
| 3 |
+
import { useAuth } from './context/AuthContext';
|
| 4 |
+
import Login from './pages/Login';
|
| 5 |
+
import TopBar, { TabId } from './components/TopBar';
|
| 6 |
+
import ChatBot from './components/ChatBot';
|
| 7 |
+
import MyAnalytics from './tabs/MyAnalytics';
|
| 8 |
+
import Analytics from './tabs/Analytics';
|
| 9 |
+
import Carriers from './tabs/Carriers';
|
| 10 |
+
import Capacity from './tabs/Capacity';
|
| 11 |
+
import AskNorthwind from './tabs/AskNorthwind';
|
| 12 |
+
import SpotterTab from './tabs/Spotter';
|
| 13 |
+
import {
|
| 14 |
+
WORKSHEET_ID,
|
| 15 |
+
CADENCE_WORKSHEET_ID,
|
| 16 |
+
CHATBOT_WELCOME,
|
| 17 |
+
CHATBOT_CADENCES_WELCOME,
|
| 18 |
+
} from './config';
|
| 19 |
+
|
| 20 |
+
// Persist the active tab so a reload restores it instead of resetting to default.
|
| 21 |
+
const TAB_KEY = 'northwind.tab';
|
| 22 |
+
|
| 23 |
+
export default function App() {
|
| 24 |
+
const { isAuthenticated } = useAuth();
|
| 25 |
+
const [tab, setTab] = useState<TabId>(
|
| 26 |
+
() => (sessionStorage.getItem(TAB_KEY) as TabId) || 'analytics',
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
const changeTab = (next: TabId) => {
|
| 30 |
+
setTab(next);
|
| 31 |
+
try {
|
| 32 |
+
sessionStorage.setItem(TAB_KEY, next);
|
| 33 |
+
} catch {
|
| 34 |
+
/* storage unavailable */
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
if (!isAuthenticated) {
|
| 39 |
+
return <Login />;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="app-shell">
|
| 44 |
+
<TopBar active={tab} onChange={changeTab} />
|
| 45 |
+
<main className="app-main">
|
| 46 |
+
{tab === 'my-analytics' && <MyAnalytics />}
|
| 47 |
+
{tab === 'analytics' && <Analytics />}
|
| 48 |
+
{tab === 'carriers' && <Carriers />}
|
| 49 |
+
{tab === 'capacity' && <Capacity />}
|
| 50 |
+
{tab === 'ask' && <AskNorthwind />}
|
| 51 |
+
{tab === 'spotter' && <SpotterTab />}
|
| 52 |
+
</main>
|
| 53 |
+
<ChatBot
|
| 54 |
+
worksheetId={tab === 'carriers' ? CADENCE_WORKSHEET_ID : WORKSHEET_ID}
|
| 55 |
+
greeting={tab === 'carriers' ? CHATBOT_CADENCES_WELCOME : CHATBOT_WELCOME}
|
| 56 |
+
/>
|
| 57 |
+
<VercelAnalytics />
|
| 58 |
+
</div>
|
| 59 |
+
);
|
| 60 |
+
}
|
demoprep_app/portal/template-tse/src/assets/northwind-logo.svg
ADDED
|
|
demoprep_app/portal/template-tse/src/components/BidModal.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { X, Gavel, Check, Send } from 'lucide-react';
|
| 3 |
+
import { SignalContext } from '../lib/signalContext';
|
| 4 |
+
import { CONTENT } from '../content';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
open: boolean;
|
| 8 |
+
context: SignalContext | null;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
// Pull a value out of the clicked-row fields by matching the column name.
|
| 13 |
+
function pick(ctx: SignalContext | null, re: RegExp): string {
|
| 14 |
+
const f = ctx?.fields.find((x) => re.test(x.name) && x.value !== '—');
|
| 15 |
+
return f ? f.value : '';
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function BidModal({ open, context, onClose }: Props) {
|
| 19 |
+
const [corridor, setCorridor] = useState('');
|
| 20 |
+
const [carrier, setCarrier] = useState('');
|
| 21 |
+
const [equipment, setEquipment] = useState('Dry Van');
|
| 22 |
+
const [targetRate, setTargetRate] = useState('');
|
| 23 |
+
const [notes, setNotes] = useState('');
|
| 24 |
+
const [submitted, setSubmitted] = useState(false);
|
| 25 |
+
|
| 26 |
+
// Pre-fill from the row the user right-clicked whenever a new bid is opened.
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (!context) return;
|
| 29 |
+
setCorridor(pick(context, /corridor|lane|origin|destination/i) || '');
|
| 30 |
+
setCarrier(pick(context, /carrier/i) || '');
|
| 31 |
+
const rate = pick(context, /rate|price|\$|cost|spend/i);
|
| 32 |
+
setTargetRate(rate ? rate.replace(/[^0-9.]/g, '') : '');
|
| 33 |
+
setNotes('');
|
| 34 |
+
setSubmitted(false);
|
| 35 |
+
}, [context]);
|
| 36 |
+
|
| 37 |
+
if (!open || !context) return null;
|
| 38 |
+
|
| 39 |
+
// Everything else from the row, shown as read-only context chips.
|
| 40 |
+
const otherFields = context.fields.filter(
|
| 41 |
+
(f) => !/corridor|lane|carrier|rate|price|cost/i.test(f.name) && f.value !== '—',
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="winback-overlay" onClick={onClose}>
|
| 46 |
+
<div className="winback-modal bid-modal" onClick={(e) => e.stopPropagation()}>
|
| 47 |
+
<div className="winback-header">
|
| 48 |
+
<div className="winback-title">
|
| 49 |
+
<Gavel size={18} />
|
| 50 |
+
<span>{CONTENT.action.modalTitle}</span>
|
| 51 |
+
</div>
|
| 52 |
+
<button className="winback-close" onClick={onClose} aria-label="Close">
|
| 53 |
+
<X size={18} />
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{submitted ? (
|
| 58 |
+
<div className="winback-success">
|
| 59 |
+
<div className="winback-success-icon">
|
| 60 |
+
<Check size={30} />
|
| 61 |
+
</div>
|
| 62 |
+
<h3>Bid request submitted</h3>
|
| 63 |
+
<p>
|
| 64 |
+
Your bid for{' '}
|
| 65 |
+
<strong>{corridor || 'this lane'}</strong>
|
| 66 |
+
{carrier ? <> to <strong>{carrier}</strong></> : null} has been sent to the
|
| 67 |
+
Northwind capacity desk.
|
| 68 |
+
</p>
|
| 69 |
+
<button className="sl-hfilter-apply bid-done" onClick={onClose}>
|
| 70 |
+
Done
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
) : (
|
| 74 |
+
<div className="bid-body">
|
| 75 |
+
<p className="bid-lead">
|
| 76 |
+
Pre-filled from the selected lane. Review and submit to request a carrier
|
| 77 |
+
bid through Northwind.
|
| 78 |
+
</p>
|
| 79 |
+
|
| 80 |
+
<div className="bid-grid">
|
| 81 |
+
<label className="bid-field">
|
| 82 |
+
<span>Freight corridor / lane</span>
|
| 83 |
+
<input value={corridor} onChange={(e) => setCorridor(e.target.value)} placeholder="e.g. Dallas, TX → Atlanta, GA" />
|
| 84 |
+
</label>
|
| 85 |
+
<label className="bid-field">
|
| 86 |
+
<span>Carrier</span>
|
| 87 |
+
<input value={carrier} onChange={(e) => setCarrier(e.target.value)} placeholder="Carrier name" />
|
| 88 |
+
</label>
|
| 89 |
+
<label className="bid-field">
|
| 90 |
+
<span>Equipment</span>
|
| 91 |
+
<select value={equipment} onChange={(e) => setEquipment(e.target.value)}>
|
| 92 |
+
<option>Dry Van</option>
|
| 93 |
+
<option>Reefer</option>
|
| 94 |
+
<option>Flatbed</option>
|
| 95 |
+
<option>Power Only</option>
|
| 96 |
+
</select>
|
| 97 |
+
</label>
|
| 98 |
+
<label className="bid-field">
|
| 99 |
+
<span>Target rate ($)</span>
|
| 100 |
+
<input value={targetRate} onChange={(e) => setTargetRate(e.target.value)} placeholder="2,300.00" inputMode="decimal" />
|
| 101 |
+
</label>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{otherFields.length > 0 && (
|
| 105 |
+
<div className="bid-context">
|
| 106 |
+
<span className="bid-context-label">From this lane</span>
|
| 107 |
+
<div className="bid-chips">
|
| 108 |
+
{otherFields.slice(0, 6).map((f) => (
|
| 109 |
+
<span className="bid-chip" key={f.name}>
|
| 110 |
+
<em>{f.name}</em> {f.value}
|
| 111 |
+
</span>
|
| 112 |
+
))}
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
|
| 117 |
+
<label className="bid-field">
|
| 118 |
+
<span>Notes for the capacity desk</span>
|
| 119 |
+
<textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Any constraints, timing, or commitments…" />
|
| 120 |
+
</label>
|
| 121 |
+
|
| 122 |
+
<div className="bid-actions">
|
| 123 |
+
<button className="winback-close-text" onClick={onClose}>Cancel</button>
|
| 124 |
+
<button className="sl-hfilter-apply bid-submit" onClick={() => setSubmitted(true)}>
|
| 125 |
+
<Send size={15} /> Submit bid request
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
}
|
demoprep_app/portal/template-tse/src/components/ChatBot.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
import { Wand2, X, ArrowUp } from 'lucide-react';
|
| 3 |
+
import { BodylessConversation } from '@thoughtspot/visual-embed-sdk';
|
| 4 |
+
import { routeMessage, ChatTurn } from '../lib/chatbot';
|
| 5 |
+
|
| 6 |
+
interface ChatBotProps {
|
| 7 |
+
/** Data model the chatbot routes analytics questions to (tab-dependent). */
|
| 8 |
+
worksheetId: string;
|
| 9 |
+
/** Opening greeting (tab-dependent). */
|
| 10 |
+
greeting: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface Msg {
|
| 14 |
+
id: number;
|
| 15 |
+
role: 'user' | 'assistant';
|
| 16 |
+
text?: string;
|
| 17 |
+
/** A rendered ThoughtSpot answer (from BodylessConversation). */
|
| 18 |
+
container?: HTMLElement;
|
| 19 |
+
loading?: boolean;
|
| 20 |
+
error?: boolean;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
let idSeq = 1;
|
| 24 |
+
|
| 25 |
+
/** Mounts a ThoughtSpot answer DOM node returned by BodylessConversation. */
|
| 26 |
+
function AnswerEmbed({ container }: { container: HTMLElement }) {
|
| 27 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
const node = ref.current;
|
| 30 |
+
if (node && container) node.appendChild(container);
|
| 31 |
+
return () => {
|
| 32 |
+
if (node && container && node.contains(container)) node.removeChild(container);
|
| 33 |
+
};
|
| 34 |
+
}, [container]);
|
| 35 |
+
return <div className="chat-answer" ref={ref} />;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export default function ChatBot({ worksheetId, greeting }: ChatBotProps) {
|
| 39 |
+
const [open, setOpen] = useState(false);
|
| 40 |
+
const [input, setInput] = useState('');
|
| 41 |
+
const [busy, setBusy] = useState(false);
|
| 42 |
+
const [msgs, setMsgs] = useState<Msg[]>([
|
| 43 |
+
{ id: 0, role: 'assistant', text: greeting },
|
| 44 |
+
]);
|
| 45 |
+
const convRef = useRef<BodylessConversation | null>(null);
|
| 46 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 47 |
+
const firstRun = useRef(true);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
scrollRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
|
| 51 |
+
}, [msgs, open]);
|
| 52 |
+
|
| 53 |
+
// When the tab context changes (worksheet/greeting), start a fresh,
|
| 54 |
+
// context-specific conversation against the new data model.
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
if (firstRun.current) {
|
| 57 |
+
firstRun.current = false;
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
convRef.current = null;
|
| 61 |
+
setMsgs([{ id: idSeq++, role: 'assistant', text: greeting }]);
|
| 62 |
+
}, [worksheetId, greeting]);
|
| 63 |
+
|
| 64 |
+
function getConversation() {
|
| 65 |
+
if (!convRef.current) {
|
| 66 |
+
convRef.current = new BodylessConversation({ worksheetId });
|
| 67 |
+
}
|
| 68 |
+
return convRef.current;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const update = (id: number, patch: Partial<Msg>) =>
|
| 72 |
+
setMsgs((m) => m.map((x) => (x.id === id ? { ...x, ...patch } : x)));
|
| 73 |
+
|
| 74 |
+
async function send() {
|
| 75 |
+
const q = input.trim();
|
| 76 |
+
if (!q || busy) return;
|
| 77 |
+
setInput('');
|
| 78 |
+
setBusy(true);
|
| 79 |
+
|
| 80 |
+
const history: ChatTurn[] = msgs
|
| 81 |
+
.filter((m) => m.text)
|
| 82 |
+
.map((m) => ({ role: m.role, content: m.text as string }));
|
| 83 |
+
|
| 84 |
+
const userMsg: Msg = { id: idSeq++, role: 'user', text: q };
|
| 85 |
+
const reply: Msg = { id: idSeq++, role: 'assistant', loading: true };
|
| 86 |
+
setMsgs((m) => [...m, userMsg, reply]);
|
| 87 |
+
|
| 88 |
+
try {
|
| 89 |
+
const result = await routeMessage(history, q);
|
| 90 |
+
if (result.kind === 'text') {
|
| 91 |
+
update(reply.id, { text: result.text, loading: false });
|
| 92 |
+
} else {
|
| 93 |
+
// Show the preamble while Spotter renders the answer.
|
| 94 |
+
update(reply.id, { text: result.preamble, loading: true });
|
| 95 |
+
const { container, error } = await getConversation().sendMessage(result.query);
|
| 96 |
+
if (error || !container) {
|
| 97 |
+
update(reply.id, {
|
| 98 |
+
loading: false,
|
| 99 |
+
error: true,
|
| 100 |
+
text:
|
| 101 |
+
(result.preamble ? result.preamble + '\n\n' : '') +
|
| 102 |
+
'I couldn’t load that analytics result. Make sure you’re signed in to ThoughtSpot and the data model is reachable.',
|
| 103 |
+
});
|
| 104 |
+
} else {
|
| 105 |
+
update(reply.id, { text: result.preamble, container, loading: false });
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
} catch {
|
| 109 |
+
update(reply.id, {
|
| 110 |
+
loading: false,
|
| 111 |
+
error: true,
|
| 112 |
+
text: 'Something went wrong. Please try again.',
|
| 113 |
+
});
|
| 114 |
+
} finally {
|
| 115 |
+
setBusy(false);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
| 120 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 121 |
+
e.preventDefault();
|
| 122 |
+
send();
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (!open) {
|
| 127 |
+
return (
|
| 128 |
+
<button
|
| 129 |
+
className="chat-fab"
|
| 130 |
+
onClick={() => setOpen(true)}
|
| 131 |
+
aria-label="Open Northwind AI"
|
| 132 |
+
>
|
| 133 |
+
<Wand2 size={22} strokeWidth={2.2} />
|
| 134 |
+
</button>
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div className="chat-panel">
|
| 140 |
+
<div className="chat-header">
|
| 141 |
+
<div className="chat-title">
|
| 142 |
+
<Wand2 size={18} />
|
| 143 |
+
<span>Northwind AI</span>
|
| 144 |
+
</div>
|
| 145 |
+
<button className="chat-close" onClick={() => setOpen(false)} aria-label="Close">
|
| 146 |
+
<X size={18} />
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div className="chat-messages" ref={scrollRef}>
|
| 151 |
+
{msgs.map((m) => (
|
| 152 |
+
<div key={m.id} className={`chat-msg ${m.role}`}>
|
| 153 |
+
<div className={`chat-bubble ${m.error ? 'is-error' : ''}`}>
|
| 154 |
+
{m.text && <p className="chat-text">{m.text}</p>}
|
| 155 |
+
{m.loading && (
|
| 156 |
+
<span className="chat-typing">
|
| 157 |
+
<i /> <i /> <i />
|
| 158 |
+
</span>
|
| 159 |
+
)}
|
| 160 |
+
{m.container && <AnswerEmbed container={m.container} />}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="chat-input-row">
|
| 167 |
+
<textarea
|
| 168 |
+
className="chat-input"
|
| 169 |
+
rows={1}
|
| 170 |
+
placeholder="Ask Northwind AI…"
|
| 171 |
+
value={input}
|
| 172 |
+
onChange={(e) => setInput(e.target.value)}
|
| 173 |
+
onKeyDown={onKeyDown}
|
| 174 |
+
/>
|
| 175 |
+
<button
|
| 176 |
+
className="chat-send"
|
| 177 |
+
onClick={send}
|
| 178 |
+
disabled={!input.trim() || busy}
|
| 179 |
+
aria-label="Send"
|
| 180 |
+
>
|
| 181 |
+
<ArrowUp size={18} />
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
demoprep_app/portal/template-tse/src/components/ColumnFilter.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Generic single-column dropdown filter. Fetches distinct values for the first
|
| 2 |
+
// matching candidate column and lets the user pick one or more; the selection is
|
| 3 |
+
// handed back to the caller as (resolvedColumn, values) so it can push a runtime
|
| 4 |
+
// filter (RuntimeFilterOp.IN). Nothing is selected by default (= no filter).
|
| 5 |
+
// Reuses the shared sl-hfilter / sl-tree styles.
|
| 6 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 7 |
+
import { ChevronDown, ChevronRight, Search, X, type LucideIcon } from 'lucide-react';
|
| 8 |
+
import { useAuth } from '../context/AuthContext';
|
| 9 |
+
import { fetchColumnValues } from '../lib/thoughtspot';
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
/** Label shown on the trigger, e.g. "Year" or "Scope". */
|
| 13 |
+
label: string;
|
| 14 |
+
icon: LucideIcon;
|
| 15 |
+
/** Candidate column names — first that returns data is used. */
|
| 16 |
+
candidates: string[];
|
| 17 |
+
/** Called with the resolved column + selected values (empty = show all). */
|
| 18 |
+
onApply: (column: string, values: string[]) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function ColumnFilter({ label, icon: Icon, candidates, onApply }: Props) {
|
| 22 |
+
const { username, password } = useAuth();
|
| 23 |
+
const [open, setOpen] = useState(false);
|
| 24 |
+
const [column, setColumn] = useState('');
|
| 25 |
+
const [values, setValues] = useState<string[]>([]);
|
| 26 |
+
const [loading, setLoading] = useState(true);
|
| 27 |
+
const [error, setError] = useState('');
|
| 28 |
+
const [query, setQuery] = useState('');
|
| 29 |
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
| 30 |
+
const [appliedCount, setAppliedCount] = useState(0);
|
| 31 |
+
const rootRef = useRef<HTMLDivElement>(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
(async () => {
|
| 35 |
+
try {
|
| 36 |
+
const res = await fetchColumnValues(username, password, candidates);
|
| 37 |
+
setColumn(res.column);
|
| 38 |
+
setValues(res.values);
|
| 39 |
+
} catch (e) {
|
| 40 |
+
setError(e instanceof Error ? e.message : `Failed to load ${label}.`);
|
| 41 |
+
} finally {
|
| 42 |
+
setLoading(false);
|
| 43 |
+
}
|
| 44 |
+
})();
|
| 45 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
if (!open) return;
|
| 50 |
+
const h = (e: MouseEvent) => {
|
| 51 |
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
| 52 |
+
};
|
| 53 |
+
document.addEventListener('mousedown', h);
|
| 54 |
+
return () => document.removeEventListener('mousedown', h);
|
| 55 |
+
}, [open]);
|
| 56 |
+
|
| 57 |
+
const filtered = useMemo(() => {
|
| 58 |
+
const q = query.trim().toLowerCase();
|
| 59 |
+
return q ? values.filter((v) => v.toLowerCase().includes(q)) : values;
|
| 60 |
+
}, [values, query]);
|
| 61 |
+
|
| 62 |
+
const allSelected = values.length > 0 && values.every((v) => selected.has(v));
|
| 63 |
+
|
| 64 |
+
function toggle(v: string) {
|
| 65 |
+
setSelected((p) => {
|
| 66 |
+
const s = new Set(p);
|
| 67 |
+
s.has(v) ? s.delete(v) : s.add(v);
|
| 68 |
+
return s;
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
function toggleAll() {
|
| 72 |
+
setSelected(allSelected ? new Set() : new Set(values));
|
| 73 |
+
}
|
| 74 |
+
function apply() {
|
| 75 |
+
const list = [...selected];
|
| 76 |
+
onApply(column, list);
|
| 77 |
+
setAppliedCount(list.length);
|
| 78 |
+
setOpen(false);
|
| 79 |
+
}
|
| 80 |
+
function clearAll() {
|
| 81 |
+
setSelected(new Set());
|
| 82 |
+
onApply(column, []);
|
| 83 |
+
setAppliedCount(0);
|
| 84 |
+
setOpen(false);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const state =
|
| 88 |
+
appliedCount === 0 ? 'All' : `${appliedCount} selected`;
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div className="sl-hfilter" ref={rootRef}>
|
| 92 |
+
<button className="sl-hfilter-trigger" onClick={() => setOpen((o) => !o)}>
|
| 93 |
+
<Icon size={15} />
|
| 94 |
+
<span>
|
| 95 |
+
{label}: {state}
|
| 96 |
+
</span>
|
| 97 |
+
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 98 |
+
</button>
|
| 99 |
+
|
| 100 |
+
{open && (
|
| 101 |
+
<div className="sl-hfilter-panel">
|
| 102 |
+
<div className="sl-hfilter-search">
|
| 103 |
+
<Search size={15} />
|
| 104 |
+
<input
|
| 105 |
+
placeholder={`Search ${label.toLowerCase()}…`}
|
| 106 |
+
value={query}
|
| 107 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 108 |
+
autoFocus
|
| 109 |
+
/>
|
| 110 |
+
{query && (
|
| 111 |
+
<button onClick={() => setQuery('')} aria-label="Clear search">
|
| 112 |
+
<X size={13} />
|
| 113 |
+
</button>
|
| 114 |
+
)}
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{loading && <div className="sl-hfilter-state">Loading…</div>}
|
| 118 |
+
{error && <div className="sl-hfilter-state sl-hfilter-error">{error}</div>}
|
| 119 |
+
|
| 120 |
+
{!loading && !error && (
|
| 121 |
+
<>
|
| 122 |
+
<label className="sl-tree-row sl-tree-all">
|
| 123 |
+
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
| 124 |
+
<span>Select all</span>
|
| 125 |
+
</label>
|
| 126 |
+
<div className="sl-tree">
|
| 127 |
+
{filtered.map((v) => (
|
| 128 |
+
<label key={v} className="sl-tree-row sl-tree-l1">
|
| 129 |
+
<input type="checkbox" checked={selected.has(v)} onChange={() => toggle(v)} />
|
| 130 |
+
<span className="sl-tree-l1-name">{v}</span>
|
| 131 |
+
</label>
|
| 132 |
+
))}
|
| 133 |
+
{filtered.length === 0 && <div className="sl-hfilter-state">No matches</div>}
|
| 134 |
+
</div>
|
| 135 |
+
<div className="sl-hfilter-actions">
|
| 136 |
+
<button className="sl-hfilter-clear" onClick={clearAll}>
|
| 137 |
+
Clear
|
| 138 |
+
</button>
|
| 139 |
+
<button className="sl-hfilter-apply" onClick={apply}>
|
| 140 |
+
Apply
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
</>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
)}
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
}
|
demoprep_app/portal/template-tse/src/components/CreateDashboardModal.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { X, LayoutDashboard, Plus, AlertCircle } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
open: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
/** Creates the liveboard; should throw on failure so the modal can show it. */
|
| 8 |
+
onCreate: (name: string, description: string) => Promise<void>;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function CreateDashboardModal({ open, onClose, onCreate }: Props) {
|
| 12 |
+
const [name, setName] = useState('');
|
| 13 |
+
const [description, setDescription] = useState('');
|
| 14 |
+
const [submitting, setSubmitting] = useState(false);
|
| 15 |
+
const [error, setError] = useState('');
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (open) {
|
| 19 |
+
setName('');
|
| 20 |
+
setDescription('');
|
| 21 |
+
setError('');
|
| 22 |
+
setSubmitting(false);
|
| 23 |
+
}
|
| 24 |
+
}, [open]);
|
| 25 |
+
|
| 26 |
+
if (!open) return null;
|
| 27 |
+
|
| 28 |
+
async function submit() {
|
| 29 |
+
if (!name.trim() || submitting) return;
|
| 30 |
+
setSubmitting(true);
|
| 31 |
+
setError('');
|
| 32 |
+
try {
|
| 33 |
+
await onCreate(name.trim(), description.trim());
|
| 34 |
+
// On success the parent closes this modal.
|
| 35 |
+
} catch (e) {
|
| 36 |
+
setError(e instanceof Error ? e.message : 'Could not create the dashboard.');
|
| 37 |
+
setSubmitting(false);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="winback-overlay" onClick={submitting ? undefined : onClose}>
|
| 43 |
+
<div className="winback-modal" onClick={(e) => e.stopPropagation()}>
|
| 44 |
+
<div className="winback-header">
|
| 45 |
+
<div className="winback-title">
|
| 46 |
+
<LayoutDashboard size={18} />
|
| 47 |
+
<span>Create New Dashboard</span>
|
| 48 |
+
</div>
|
| 49 |
+
<button
|
| 50 |
+
className="winback-close"
|
| 51 |
+
onClick={onClose}
|
| 52 |
+
disabled={submitting}
|
| 53 |
+
aria-label="Close"
|
| 54 |
+
>
|
| 55 |
+
<X size={18} />
|
| 56 |
+
</button>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="winback-body">
|
| 60 |
+
{error && (
|
| 61 |
+
<div className="ma-inline-error">
|
| 62 |
+
<AlertCircle size={15} /> {error}
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
<div className="winback-field" style={{ marginBottom: 16 }}>
|
| 66 |
+
<label className="winback-label">Dashboard name</label>
|
| 67 |
+
<input
|
| 68 |
+
className="login-input"
|
| 69 |
+
value={name}
|
| 70 |
+
onChange={(e) => setName(e.target.value)}
|
| 71 |
+
placeholder="e.g. Q3 Pipeline Review"
|
| 72 |
+
autoFocus
|
| 73 |
+
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
<div className="winback-field">
|
| 77 |
+
<label className="winback-label">Description (optional)</label>
|
| 78 |
+
<input
|
| 79 |
+
className="login-input"
|
| 80 |
+
value={description}
|
| 81 |
+
onChange={(e) => setDescription(e.target.value)}
|
| 82 |
+
placeholder="What is this dashboard for?"
|
| 83 |
+
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className="winback-footer">
|
| 89 |
+
<button
|
| 90 |
+
className="winback-btn-secondary"
|
| 91 |
+
onClick={onClose}
|
| 92 |
+
disabled={submitting}
|
| 93 |
+
>
|
| 94 |
+
Cancel
|
| 95 |
+
</button>
|
| 96 |
+
<button
|
| 97 |
+
className="winback-btn-primary"
|
| 98 |
+
onClick={submit}
|
| 99 |
+
disabled={!name.trim() || submitting}
|
| 100 |
+
>
|
| 101 |
+
{submitting ? <span className="btn-spinner" /> : <Plus size={16} />}
|
| 102 |
+
{submitting ? 'Creating…' : 'Create Dashboard'}
|
| 103 |
+
</button>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|
demoprep_app/portal/template-tse/src/components/DateRangeFilter.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Host-side date-range filter for the Analytics tab. Presets + a custom range
|
| 2 |
+
// produce an epoch-SECONDS window that the tab pushes to the liveboard as a
|
| 3 |
+
// runtime filter on the Cadence Create Date column.
|
| 4 |
+
import { useEffect, useRef, useState } from 'react';
|
| 5 |
+
import { CalendarRange, ChevronDown, ChevronRight, X } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
/** Epoch SECONDS range (either bound optional). null = no date filter. */
|
| 8 |
+
export interface DateSelection {
|
| 9 |
+
start: number | null;
|
| 10 |
+
end: number | null;
|
| 11 |
+
label: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
onApply: (sel: DateSelection | null) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
type PresetId = 'all' | '7d' | '30d' | '90d' | '12m' | 'ytd' | 'custom';
|
| 19 |
+
|
| 20 |
+
const PRESETS: { id: PresetId; label: string }[] = [
|
| 21 |
+
{ id: 'all', label: 'All time' },
|
| 22 |
+
{ id: '7d', label: 'Last 7 days' },
|
| 23 |
+
{ id: '30d', label: 'Last 30 days' },
|
| 24 |
+
{ id: '90d', label: 'Last 90 days' },
|
| 25 |
+
{ id: '12m', label: 'Last 12 months' },
|
| 26 |
+
{ id: 'ytd', label: 'Year to date' },
|
| 27 |
+
{ id: 'custom', label: 'Custom range' },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
| 31 |
+
const endOfDay = (d: Date) =>
|
| 32 |
+
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59).getTime();
|
| 33 |
+
const sec = (ms: number) => Math.floor(ms / 1000);
|
| 34 |
+
|
| 35 |
+
function presetRange(id: PresetId): { start: number | null; end: number | null } {
|
| 36 |
+
const now = new Date();
|
| 37 |
+
const end = sec(endOfDay(now));
|
| 38 |
+
const daysAgo = (n: number) => {
|
| 39 |
+
const d = new Date();
|
| 40 |
+
d.setDate(d.getDate() - n);
|
| 41 |
+
return sec(startOfDay(d));
|
| 42 |
+
};
|
| 43 |
+
switch (id) {
|
| 44 |
+
case '7d':
|
| 45 |
+
return { start: daysAgo(7), end };
|
| 46 |
+
case '30d':
|
| 47 |
+
return { start: daysAgo(30), end };
|
| 48 |
+
case '90d':
|
| 49 |
+
return { start: daysAgo(90), end };
|
| 50 |
+
case '12m': {
|
| 51 |
+
const d = new Date();
|
| 52 |
+
d.setFullYear(d.getFullYear() - 1);
|
| 53 |
+
return { start: sec(startOfDay(d)), end };
|
| 54 |
+
}
|
| 55 |
+
case 'ytd':
|
| 56 |
+
return { start: sec(new Date(now.getFullYear(), 0, 1).getTime()), end };
|
| 57 |
+
default:
|
| 58 |
+
return { start: null, end: null };
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export default function DateRangeFilter({ onApply }: Props) {
|
| 63 |
+
const [open, setOpen] = useState(false);
|
| 64 |
+
const [active, setActive] = useState<PresetId>('all');
|
| 65 |
+
const [label, setLabel] = useState('All time');
|
| 66 |
+
const [from, setFrom] = useState('');
|
| 67 |
+
const [to, setTo] = useState('');
|
| 68 |
+
const rootRef = useRef<HTMLDivElement>(null);
|
| 69 |
+
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
if (!open) return;
|
| 72 |
+
const h = (e: MouseEvent) => {
|
| 73 |
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
| 74 |
+
};
|
| 75 |
+
document.addEventListener('mousedown', h);
|
| 76 |
+
return () => document.removeEventListener('mousedown', h);
|
| 77 |
+
}, [open]);
|
| 78 |
+
|
| 79 |
+
function choosePreset(id: PresetId) {
|
| 80 |
+
setActive(id);
|
| 81 |
+
if (id === 'custom') return; // wait for Apply on custom inputs
|
| 82 |
+
const { start, end } = presetRange(id);
|
| 83 |
+
const lbl = PRESETS.find((p) => p.id === id)!.label;
|
| 84 |
+
setLabel(lbl);
|
| 85 |
+
setOpen(false);
|
| 86 |
+
onApply(id === 'all' ? null : { start, end, label: lbl });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function applyCustom() {
|
| 90 |
+
const start = from ? sec(startOfDay(new Date(from))) : null;
|
| 91 |
+
const end = to ? sec(endOfDay(new Date(to))) : null;
|
| 92 |
+
if (start == null && end == null) {
|
| 93 |
+
setLabel('All time');
|
| 94 |
+
onApply(null);
|
| 95 |
+
} else {
|
| 96 |
+
const lbl = `${from || '…'} → ${to || '…'}`;
|
| 97 |
+
setLabel(lbl);
|
| 98 |
+
onApply({ start, end, label: lbl });
|
| 99 |
+
}
|
| 100 |
+
setOpen(false);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
return (
|
| 104 |
+
<div className="sl-datefilter" ref={rootRef}>
|
| 105 |
+
<button className="sl-hfilter-trigger" onClick={() => setOpen((o) => !o)}>
|
| 106 |
+
<CalendarRange size={15} />
|
| 107 |
+
<span>Created: {label}</span>
|
| 108 |
+
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 109 |
+
</button>
|
| 110 |
+
|
| 111 |
+
{open && (
|
| 112 |
+
<div className="sl-hfilter-panel sl-datefilter-panel">
|
| 113 |
+
<div className="sl-date-presets">
|
| 114 |
+
{PRESETS.map((p) => (
|
| 115 |
+
<button
|
| 116 |
+
key={p.id}
|
| 117 |
+
className={`sl-date-preset${active === p.id ? ' is-active' : ''}`}
|
| 118 |
+
onClick={() => choosePreset(p.id)}
|
| 119 |
+
>
|
| 120 |
+
{p.label}
|
| 121 |
+
</button>
|
| 122 |
+
))}
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{active === 'custom' && (
|
| 126 |
+
<div className="sl-date-custom">
|
| 127 |
+
<label>
|
| 128 |
+
<span>From</span>
|
| 129 |
+
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
| 130 |
+
</label>
|
| 131 |
+
<label>
|
| 132 |
+
<span>To</span>
|
| 133 |
+
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
| 134 |
+
</label>
|
| 135 |
+
<div className="sl-hfilter-actions">
|
| 136 |
+
<button
|
| 137 |
+
className="sl-hfilter-clear"
|
| 138 |
+
onClick={() => {
|
| 139 |
+
setFrom('');
|
| 140 |
+
setTo('');
|
| 141 |
+
}}
|
| 142 |
+
>
|
| 143 |
+
<X size={13} /> Reset
|
| 144 |
+
</button>
|
| 145 |
+
<button className="sl-hfilter-apply" onClick={applyCustom}>
|
| 146 |
+
Apply
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
}
|
demoprep_app/portal/template-tse/src/components/NorthwindLogo.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Official Northwind logo (fetched from northwind.com, "on-light" variant).
|
| 2 |
+
// Inlined so its ink can follow `currentColor` — dark evergreen on light
|
| 3 |
+
// surfaces, light on dark surfaces — while keeping the lime accent. Color is
|
| 4 |
+
// set by the host (.sl-logo → var(--sl-evergreen)); height comes from `size`.
|
| 5 |
+
import logoRaw from '../assets/northwind-logo.svg?raw';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
className?: string;
|
| 9 |
+
/** Logo height in px (default 26). */
|
| 10 |
+
size?: number;
|
| 11 |
+
/** Kept for API compatibility; the official lockup always includes the wordmark. */
|
| 12 |
+
wordmark?: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function NorthwindLogo({ className, size = 26 }: Props) {
|
| 16 |
+
return (
|
| 17 |
+
<span
|
| 18 |
+
className={`sl-logo ${className ?? ''}`}
|
| 19 |
+
style={{ height: size }}
|
| 20 |
+
role="img"
|
| 21 |
+
aria-label="Northwind"
|
| 22 |
+
dangerouslySetInnerHTML={{ __html: logoRaw }}
|
| 23 |
+
/>
|
| 24 |
+
);
|
| 25 |
+
}
|
demoprep_app/portal/template-tse/src/components/PinModal.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// In-app "name this report" dialog used before pinning an answer to the
|
| 2 |
+
// dashboard. Replaces window.prompt() (browser-native prompts are invisible in
|
| 3 |
+
// screen shares / recordings) with an app-rendered, theme-aware modal.
|
| 4 |
+
import { useEffect, useRef, useState } from 'react';
|
| 5 |
+
import { Pin, X } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
open: boolean;
|
| 9 |
+
defaultName?: string;
|
| 10 |
+
onCancel: () => void;
|
| 11 |
+
onConfirm: (name: string) => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function PinModal({ open, defaultName = 'New report', onCancel, onConfirm }: Props) {
|
| 15 |
+
const [name, setName] = useState(defaultName);
|
| 16 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
if (open) {
|
| 20 |
+
setName(defaultName);
|
| 21 |
+
// Focus + select so the user can type over the default immediately.
|
| 22 |
+
setTimeout(() => {
|
| 23 |
+
inputRef.current?.focus();
|
| 24 |
+
inputRef.current?.select();
|
| 25 |
+
}, 0);
|
| 26 |
+
}
|
| 27 |
+
}, [open, defaultName]);
|
| 28 |
+
|
| 29 |
+
if (!open) return null;
|
| 30 |
+
|
| 31 |
+
function confirm() {
|
| 32 |
+
const trimmed = name.trim();
|
| 33 |
+
if (trimmed) onConfirm(trimmed);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="sl-pin-overlay" onClick={onCancel}>
|
| 38 |
+
<div
|
| 39 |
+
className="sl-pin-modal"
|
| 40 |
+
onClick={(e) => e.stopPropagation()}
|
| 41 |
+
role="dialog"
|
| 42 |
+
aria-modal="true"
|
| 43 |
+
>
|
| 44 |
+
<div className="sl-pin-header">
|
| 45 |
+
<div className="sl-pin-title">
|
| 46 |
+
<Pin size={16} />
|
| 47 |
+
<span>Pin to dashboard</span>
|
| 48 |
+
</div>
|
| 49 |
+
<button className="sl-pin-close" onClick={onCancel} aria-label="Close">
|
| 50 |
+
<X size={18} />
|
| 51 |
+
</button>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="sl-pin-body">
|
| 54 |
+
<label className="sl-pin-label" htmlFor="sl-pin-name">
|
| 55 |
+
Name this report
|
| 56 |
+
</label>
|
| 57 |
+
<input
|
| 58 |
+
id="sl-pin-name"
|
| 59 |
+
ref={inputRef}
|
| 60 |
+
className="sl-pin-input"
|
| 61 |
+
value={name}
|
| 62 |
+
onChange={(e) => setName(e.target.value)}
|
| 63 |
+
onKeyDown={(e) => {
|
| 64 |
+
if (e.key === 'Enter') confirm();
|
| 65 |
+
if (e.key === 'Escape') onCancel();
|
| 66 |
+
}}
|
| 67 |
+
placeholder="New report"
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
<div className="sl-pin-actions">
|
| 71 |
+
<button className="sl-pin-cancel" onClick={onCancel}>
|
| 72 |
+
Cancel
|
| 73 |
+
</button>
|
| 74 |
+
<button className="sl-pin-confirm" onClick={confirm} disabled={!name.trim()}>
|
| 75 |
+
<Pin size={15} /> Pin
|
| 76 |
+
</button>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
demoprep_app/portal/template-tse/src/components/RepFilter.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Standalone "Rep" dropdown filter. Fetches distinct Rep Name values and lets
|
| 2 |
+
// the user pick one or more; the selection is handed back to the Analytics tab,
|
| 3 |
+
// which pushes it to the liveboard as a runtime filter on the Rep Name column
|
| 4 |
+
// (RuntimeFilterOp.IN — confirmed via the SDK reference). Reuses the shared
|
| 5 |
+
// sl-hfilter / sl-tree styles.
|
| 6 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 7 |
+
import { ChevronDown, ChevronRight, Search, Users, X } from 'lucide-react';
|
| 8 |
+
import { useAuth } from '../context/AuthContext';
|
| 9 |
+
import { fetchRepNames } from '../lib/thoughtspot';
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
/** Called with the selected rep names (empty = no filter / all reps). */
|
| 13 |
+
onApply: (reps: string[]) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function RepFilter({ onApply }: Props) {
|
| 17 |
+
const { username, password } = useAuth();
|
| 18 |
+
const [open, setOpen] = useState(false);
|
| 19 |
+
const [reps, setReps] = useState<string[]>([]);
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
+
const [error, setError] = useState('');
|
| 22 |
+
const [query, setQuery] = useState('');
|
| 23 |
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
| 24 |
+
const [appliedCount, setAppliedCount] = useState(0);
|
| 25 |
+
const rootRef = useRef<HTMLDivElement>(null);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
(async () => {
|
| 29 |
+
try {
|
| 30 |
+
setReps(await fetchRepNames(username, password));
|
| 31 |
+
} catch (e) {
|
| 32 |
+
setError(e instanceof Error ? e.message : 'Failed to load reps.');
|
| 33 |
+
} finally {
|
| 34 |
+
setLoading(false);
|
| 35 |
+
}
|
| 36 |
+
})();
|
| 37 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
if (!open) return;
|
| 42 |
+
const h = (e: MouseEvent) => {
|
| 43 |
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
| 44 |
+
};
|
| 45 |
+
document.addEventListener('mousedown', h);
|
| 46 |
+
return () => document.removeEventListener('mousedown', h);
|
| 47 |
+
}, [open]);
|
| 48 |
+
|
| 49 |
+
const filtered = useMemo(() => {
|
| 50 |
+
const q = query.trim().toLowerCase();
|
| 51 |
+
return q ? reps.filter((r) => r.toLowerCase().includes(q)) : reps;
|
| 52 |
+
}, [reps, query]);
|
| 53 |
+
|
| 54 |
+
const allSelected = reps.length > 0 && reps.every((r) => selected.has(r));
|
| 55 |
+
|
| 56 |
+
function toggle(r: string) {
|
| 57 |
+
setSelected((p) => {
|
| 58 |
+
const s = new Set(p);
|
| 59 |
+
s.has(r) ? s.delete(r) : s.add(r);
|
| 60 |
+
return s;
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
function toggleAll() {
|
| 64 |
+
setSelected(allSelected ? new Set() : new Set(reps));
|
| 65 |
+
}
|
| 66 |
+
function apply() {
|
| 67 |
+
const list = [...selected];
|
| 68 |
+
onApply(list);
|
| 69 |
+
setAppliedCount(list.length);
|
| 70 |
+
setOpen(false);
|
| 71 |
+
}
|
| 72 |
+
function clearAll() {
|
| 73 |
+
setSelected(new Set());
|
| 74 |
+
onApply([]);
|
| 75 |
+
setAppliedCount(0);
|
| 76 |
+
setOpen(false);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const label =
|
| 80 |
+
appliedCount === 0 ? 'All reps' : `${appliedCount} rep${appliedCount === 1 ? '' : 's'} selected`;
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className="sl-hfilter" ref={rootRef}>
|
| 84 |
+
<button className="sl-hfilter-trigger" onClick={() => setOpen((o) => !o)}>
|
| 85 |
+
<Users size={15} />
|
| 86 |
+
<span>Rep: {label}</span>
|
| 87 |
+
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 88 |
+
</button>
|
| 89 |
+
|
| 90 |
+
{open && (
|
| 91 |
+
<div className="sl-hfilter-panel">
|
| 92 |
+
<div className="sl-hfilter-search">
|
| 93 |
+
<Search size={15} />
|
| 94 |
+
<input
|
| 95 |
+
placeholder="Search reps…"
|
| 96 |
+
value={query}
|
| 97 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 98 |
+
autoFocus
|
| 99 |
+
/>
|
| 100 |
+
{query && (
|
| 101 |
+
<button onClick={() => setQuery('')} aria-label="Clear search">
|
| 102 |
+
<X size={13} />
|
| 103 |
+
</button>
|
| 104 |
+
)}
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{loading && <div className="sl-hfilter-state">Loading…</div>}
|
| 108 |
+
{error && <div className="sl-hfilter-state sl-hfilter-error">{error}</div>}
|
| 109 |
+
|
| 110 |
+
{!loading && !error && (
|
| 111 |
+
<>
|
| 112 |
+
<label className="sl-tree-row sl-tree-all">
|
| 113 |
+
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
| 114 |
+
<span>Select all</span>
|
| 115 |
+
</label>
|
| 116 |
+
<div className="sl-tree">
|
| 117 |
+
{filtered.map((r) => (
|
| 118 |
+
<label key={r} className="sl-tree-row sl-tree-l1">
|
| 119 |
+
<input type="checkbox" checked={selected.has(r)} onChange={() => toggle(r)} />
|
| 120 |
+
<span className="sl-tree-l1-name">{r}</span>
|
| 121 |
+
</label>
|
| 122 |
+
))}
|
| 123 |
+
{filtered.length === 0 && <div className="sl-hfilter-state">No matches</div>}
|
| 124 |
+
</div>
|
| 125 |
+
<div className="sl-hfilter-actions">
|
| 126 |
+
<button className="sl-hfilter-clear" onClick={clearAll}>
|
| 127 |
+
Clear
|
| 128 |
+
</button>
|
| 129 |
+
<button className="sl-hfilter-apply" onClick={apply}>
|
| 130 |
+
Apply
|
| 131 |
+
</button>
|
| 132 |
+
</div>
|
| 133 |
+
</>
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
demoprep_app/portal/template-tse/src/components/RepHierarchyFilter.tsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Host-side 3-level tree filter for the Analytics tab:
|
| 2 |
+
// Rep Segment → Rep Name → Cadence Name. Options are fetched from the model via
|
| 3 |
+
// searchdata; the selection is collapsed into per-level value sets which the tab
|
| 4 |
+
// pushes to the liveboard as runtime filters.
|
| 5 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 6 |
+
import { ChevronRight, ChevronDown, Search, SlidersHorizontal, X } from 'lucide-react';
|
| 7 |
+
import { useAuth } from '../context/AuthContext';
|
| 8 |
+
import { fetchSegmentRepHierarchy, SegmentNode } from '../lib/thoughtspot';
|
| 9 |
+
|
| 10 |
+
/** Selection collapsed into per-level value sets for runtime filters. */
|
| 11 |
+
export interface HierarchySelection {
|
| 12 |
+
segments: string[];
|
| 13 |
+
reps: string[];
|
| 14 |
+
cadences: string[];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface Props {
|
| 18 |
+
onApply: (selection: HierarchySelection) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const SEP = '||';
|
| 22 |
+
const leafKey = (s: string, r: string, c: string) => `${s}${SEP}${r}${SEP}${c}`;
|
| 23 |
+
|
| 24 |
+
type RepLike = { rep: string; cadences: string[] };
|
| 25 |
+
|
| 26 |
+
export default function RepHierarchyFilter({ onApply }: Props) {
|
| 27 |
+
const { username, password } = useAuth();
|
| 28 |
+
const [open, setOpen] = useState(false);
|
| 29 |
+
const [nodes, setNodes] = useState<SegmentNode[]>([]);
|
| 30 |
+
const [loading, setLoading] = useState(true);
|
| 31 |
+
const [error, setError] = useState('');
|
| 32 |
+
const [query, setQuery] = useState('');
|
| 33 |
+
const [expSeg, setExpSeg] = useState<Set<string>>(new Set());
|
| 34 |
+
const [expRep, setExpRep] = useState<Set<string>>(new Set());
|
| 35 |
+
const [selected, setSelected] = useState<Set<string>>(new Set()); // leaf keys
|
| 36 |
+
const [appliedCount, setAppliedCount] = useState(0);
|
| 37 |
+
const rootRef = useRef<HTMLDivElement>(null);
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
(async () => {
|
| 41 |
+
try {
|
| 42 |
+
setNodes(await fetchSegmentRepHierarchy(username, password));
|
| 43 |
+
} catch (e) {
|
| 44 |
+
setError(e instanceof Error ? e.message : 'Failed to load filter options.');
|
| 45 |
+
} finally {
|
| 46 |
+
setLoading(false);
|
| 47 |
+
}
|
| 48 |
+
})();
|
| 49 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (!open) return;
|
| 54 |
+
const h = (e: MouseEvent) => {
|
| 55 |
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
| 56 |
+
};
|
| 57 |
+
document.addEventListener('mousedown', h);
|
| 58 |
+
return () => document.removeEventListener('mousedown', h);
|
| 59 |
+
}, [open]);
|
| 60 |
+
|
| 61 |
+
const segLeaves = (n: SegmentNode) =>
|
| 62 |
+
n.reps.flatMap((r) => r.cadences.map((c) => leafKey(n.segment, r.rep, c)));
|
| 63 |
+
const repLeaves = (seg: string, r: RepLike) => r.cadences.map((c) => leafKey(seg, r.rep, c));
|
| 64 |
+
const allLeaves = useMemo(() => nodes.flatMap(segLeaves), [nodes]);
|
| 65 |
+
|
| 66 |
+
const filtered = useMemo(() => {
|
| 67 |
+
const q = query.trim().toLowerCase();
|
| 68 |
+
if (!q) return nodes;
|
| 69 |
+
return nodes
|
| 70 |
+
.map((n) => {
|
| 71 |
+
const segMatch = n.segment.toLowerCase().includes(q);
|
| 72 |
+
const reps = n.reps
|
| 73 |
+
.map((r) => {
|
| 74 |
+
const repMatch = segMatch || r.rep.toLowerCase().includes(q);
|
| 75 |
+
const cadences = repMatch ? r.cadences : r.cadences.filter((c) => c.toLowerCase().includes(q));
|
| 76 |
+
return repMatch || cadences.length ? { rep: r.rep, cadences } : null;
|
| 77 |
+
})
|
| 78 |
+
.filter((r): r is RepLike => r != null);
|
| 79 |
+
return segMatch || reps.length ? { segment: n.segment, reps } : null;
|
| 80 |
+
})
|
| 81 |
+
.filter((n): n is SegmentNode => n != null);
|
| 82 |
+
}, [nodes, query]);
|
| 83 |
+
|
| 84 |
+
function nodeState(keys: string[]): 'none' | 'some' | 'all' {
|
| 85 |
+
if (keys.length === 0) return 'none';
|
| 86 |
+
const sel = keys.filter((k) => selected.has(k)).length;
|
| 87 |
+
if (sel === 0) return 'none';
|
| 88 |
+
return sel === keys.length ? 'all' : 'some';
|
| 89 |
+
}
|
| 90 |
+
function toggleKeys(keys: string[]) {
|
| 91 |
+
const all = nodeState(keys) === 'all';
|
| 92 |
+
setSelected((p) => {
|
| 93 |
+
const s = new Set(p);
|
| 94 |
+
keys.forEach((k) => (all ? s.delete(k) : s.add(k)));
|
| 95 |
+
return s;
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
function toggleLeaf(k: string) {
|
| 99 |
+
setSelected((p) => {
|
| 100 |
+
const s = new Set(p);
|
| 101 |
+
s.has(k) ? s.delete(k) : s.add(k);
|
| 102 |
+
return s;
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const allSelected = allLeaves.length > 0 && allLeaves.every((k) => selected.has(k));
|
| 107 |
+
|
| 108 |
+
function deriveSelection(): HierarchySelection {
|
| 109 |
+
const segments = new Set<string>();
|
| 110 |
+
const reps = new Set<string>();
|
| 111 |
+
const cadences = new Set<string>();
|
| 112 |
+
for (const key of selected) {
|
| 113 |
+
const [s, r, c] = key.split(SEP);
|
| 114 |
+
segments.add(s);
|
| 115 |
+
reps.add(r);
|
| 116 |
+
cadences.add(c);
|
| 117 |
+
}
|
| 118 |
+
return { segments: [...segments], reps: [...reps], cadences: [...cadences] };
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function apply() {
|
| 122 |
+
onApply(deriveSelection());
|
| 123 |
+
setAppliedCount(selected.size);
|
| 124 |
+
setOpen(false);
|
| 125 |
+
}
|
| 126 |
+
function clearAll() {
|
| 127 |
+
setSelected(new Set());
|
| 128 |
+
onApply({ segments: [], reps: [], cadences: [] });
|
| 129 |
+
setAppliedCount(0);
|
| 130 |
+
setOpen(false);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const label =
|
| 134 |
+
appliedCount === 0
|
| 135 |
+
? 'All segments & cadences'
|
| 136 |
+
: `${appliedCount} cadence${appliedCount === 1 ? '' : 's'} selected`;
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div className="sl-hfilter" ref={rootRef}>
|
| 140 |
+
<button className="sl-hfilter-trigger" onClick={() => setOpen((o) => !o)}>
|
| 141 |
+
<SlidersHorizontal size={15} />
|
| 142 |
+
<span>{label}</span>
|
| 143 |
+
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 144 |
+
</button>
|
| 145 |
+
|
| 146 |
+
{open && (
|
| 147 |
+
<div className="sl-hfilter-panel">
|
| 148 |
+
<div className="sl-hfilter-search">
|
| 149 |
+
<Search size={15} />
|
| 150 |
+
<input
|
| 151 |
+
placeholder="Search segments, reps or cadences…"
|
| 152 |
+
value={query}
|
| 153 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 154 |
+
autoFocus
|
| 155 |
+
/>
|
| 156 |
+
{query && (
|
| 157 |
+
<button onClick={() => setQuery('')} aria-label="Clear search">
|
| 158 |
+
<X size={13} />
|
| 159 |
+
</button>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{loading && <div className="sl-hfilter-state">Loading…</div>}
|
| 164 |
+
{error && <div className="sl-hfilter-state sl-hfilter-error">{error}</div>}
|
| 165 |
+
|
| 166 |
+
{!loading && !error && (
|
| 167 |
+
<>
|
| 168 |
+
<label className="sl-tree-row sl-tree-all">
|
| 169 |
+
<input type="checkbox" checked={allSelected} onChange={() => toggleKeys(allLeaves)} />
|
| 170 |
+
<span>Select all</span>
|
| 171 |
+
</label>
|
| 172 |
+
|
| 173 |
+
<div className="sl-tree">
|
| 174 |
+
{filtered.map((n) => {
|
| 175 |
+
const sKeys = segLeaves(n);
|
| 176 |
+
const sState = nodeState(sKeys);
|
| 177 |
+
const sOpen = expSeg.has(n.segment) || !!query;
|
| 178 |
+
return (
|
| 179 |
+
<div key={n.segment}>
|
| 180 |
+
<div className="sl-tree-row sl-tree-l1">
|
| 181 |
+
<button
|
| 182 |
+
className="sl-tree-caret"
|
| 183 |
+
onClick={() =>
|
| 184 |
+
setExpSeg((p) => {
|
| 185 |
+
const s = new Set(p);
|
| 186 |
+
s.has(n.segment) ? s.delete(n.segment) : s.add(n.segment);
|
| 187 |
+
return s;
|
| 188 |
+
})
|
| 189 |
+
}
|
| 190 |
+
>
|
| 191 |
+
{sOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 192 |
+
</button>
|
| 193 |
+
<input
|
| 194 |
+
type="checkbox"
|
| 195 |
+
checked={sState === 'all'}
|
| 196 |
+
ref={(el) => el && (el.indeterminate = sState === 'some')}
|
| 197 |
+
onChange={() => toggleKeys(sKeys)}
|
| 198 |
+
/>
|
| 199 |
+
<span className="sl-tree-l1-name">{n.segment}</span>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{sOpen &&
|
| 203 |
+
n.reps.map((r) => {
|
| 204 |
+
const rKeys = repLeaves(n.segment, r);
|
| 205 |
+
const rState = nodeState(rKeys);
|
| 206 |
+
const rId = `${n.segment}${SEP}${r.rep}`;
|
| 207 |
+
const rOpen = expRep.has(rId) || !!query;
|
| 208 |
+
return (
|
| 209 |
+
<div key={rId}>
|
| 210 |
+
<div className="sl-tree-row sl-tree-l2">
|
| 211 |
+
<button
|
| 212 |
+
className="sl-tree-caret"
|
| 213 |
+
onClick={() =>
|
| 214 |
+
setExpRep((p) => {
|
| 215 |
+
const s = new Set(p);
|
| 216 |
+
s.has(rId) ? s.delete(rId) : s.add(rId);
|
| 217 |
+
return s;
|
| 218 |
+
})
|
| 219 |
+
}
|
| 220 |
+
>
|
| 221 |
+
{rOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 222 |
+
</button>
|
| 223 |
+
<input
|
| 224 |
+
type="checkbox"
|
| 225 |
+
checked={rState === 'all'}
|
| 226 |
+
ref={(el) => el && (el.indeterminate = rState === 'some')}
|
| 227 |
+
onChange={() => toggleKeys(rKeys)}
|
| 228 |
+
/>
|
| 229 |
+
<span>{r.rep}</span>
|
| 230 |
+
</div>
|
| 231 |
+
{rOpen &&
|
| 232 |
+
r.cadences.map((c) => {
|
| 233 |
+
const k = leafKey(n.segment, r.rep, c);
|
| 234 |
+
return (
|
| 235 |
+
<label key={k} className="sl-tree-row sl-tree-l3">
|
| 236 |
+
<input
|
| 237 |
+
type="checkbox"
|
| 238 |
+
checked={selected.has(k)}
|
| 239 |
+
onChange={() => toggleLeaf(k)}
|
| 240 |
+
/>
|
| 241 |
+
<span>{c}</span>
|
| 242 |
+
</label>
|
| 243 |
+
);
|
| 244 |
+
})}
|
| 245 |
+
</div>
|
| 246 |
+
);
|
| 247 |
+
})}
|
| 248 |
+
</div>
|
| 249 |
+
);
|
| 250 |
+
})}
|
| 251 |
+
{filtered.length === 0 && <div className="sl-hfilter-state">No matches</div>}
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<div className="sl-hfilter-actions">
|
| 255 |
+
<button className="sl-hfilter-clear" onClick={clearAll}>
|
| 256 |
+
Clear
|
| 257 |
+
</button>
|
| 258 |
+
<button className="sl-hfilter-apply" onClick={apply}>
|
| 259 |
+
Apply
|
| 260 |
+
</button>
|
| 261 |
+
</div>
|
| 262 |
+
</>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
);
|
| 268 |
+
}
|
demoprep_app/portal/template-tse/src/components/TopBar.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
BarChart3,
|
| 3 |
+
LayoutGrid,
|
| 4 |
+
Sparkles,
|
| 5 |
+
Truck,
|
| 6 |
+
Activity,
|
| 7 |
+
Bot,
|
| 8 |
+
ChevronDown,
|
| 9 |
+
LogOut,
|
| 10 |
+
Sun,
|
| 11 |
+
Moon,
|
| 12 |
+
Crown,
|
| 13 |
+
Check,
|
| 14 |
+
} from 'lucide-react';
|
| 15 |
+
import { useEffect, useRef, useState } from 'react';
|
| 16 |
+
import { useAuth } from '../context/AuthContext';
|
| 17 |
+
import { useTheme } from '../context/ThemeContext';
|
| 18 |
+
import { useTier } from '../context/TierContext';
|
| 19 |
+
import NorthwindLogo from './NorthwindLogo';
|
| 20 |
+
import { CONTENT } from '../content';
|
| 21 |
+
import { FLAGS } from '../flags';
|
| 22 |
+
|
| 23 |
+
export type TabId = 'my-analytics' | 'analytics' | 'carriers' | 'capacity' | 'ask' | 'spotter';
|
| 24 |
+
|
| 25 |
+
const TABS: { id: TabId; label: string; icon: typeof BarChart3 }[] = [
|
| 26 |
+
{ id: 'my-analytics', label: CONTENT.tabs.myReports, icon: LayoutGrid },
|
| 27 |
+
{ id: 'analytics', label: CONTENT.tabs.analytics, icon: BarChart3 },
|
| 28 |
+
{ id: 'carriers', label: CONTENT.tabs.inline, icon: Truck },
|
| 29 |
+
{ id: 'capacity', label: CONTENT.tabs.action, icon: Activity },
|
| 30 |
+
{ id: 'ask', label: CONTENT.tabs.ask, icon: Sparkles },
|
| 31 |
+
{ id: 'spotter', label: CONTENT.tabs.spotter, icon: Bot },
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
// Decorative Northwind platform nav (sets product context, non-interactive).
|
| 35 |
+
const PLATFORM_NAV = [...CONTENT.platformNav];
|
| 36 |
+
|
| 37 |
+
interface Props {
|
| 38 |
+
active: TabId;
|
| 39 |
+
onChange: (tab: TabId) => void;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export default function TopBar({ active, onChange }: Props) {
|
| 43 |
+
const { username, logout } = useAuth();
|
| 44 |
+
const { theme, toggle } = useTheme();
|
| 45 |
+
const { tier, setTier } = useTier();
|
| 46 |
+
const [menuOpen, setMenuOpen] = useState(false);
|
| 47 |
+
const [tierOpen, setTierOpen] = useState(false);
|
| 48 |
+
const initials = (username || 'U').slice(0, 2).toUpperCase();
|
| 49 |
+
const tierRef = useRef<HTMLDivElement>(null);
|
| 50 |
+
const userRef = useRef<HTMLDivElement>(null);
|
| 51 |
+
|
| 52 |
+
// Close either dropdown when clicking anywhere outside it.
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
if (!menuOpen && !tierOpen) return;
|
| 55 |
+
const onDown = (e: MouseEvent) => {
|
| 56 |
+
const t = e.target as Node;
|
| 57 |
+
if (tierRef.current && !tierRef.current.contains(t)) setTierOpen(false);
|
| 58 |
+
if (userRef.current && !userRef.current.contains(t)) setMenuOpen(false);
|
| 59 |
+
};
|
| 60 |
+
document.addEventListener('mousedown', onDown);
|
| 61 |
+
return () => document.removeEventListener('mousedown', onDown);
|
| 62 |
+
}, [menuOpen, tierOpen]);
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<header className="topbar">
|
| 66 |
+
<div className="topbar-left">
|
| 67 |
+
<NorthwindLogo className="topbar-logo" />
|
| 68 |
+
<nav className="topbar-platform-nav">
|
| 69 |
+
{PLATFORM_NAV.map((item) => (
|
| 70 |
+
<span key={item} className="topbar-platform-link">
|
| 71 |
+
{item}
|
| 72 |
+
</span>
|
| 73 |
+
))}
|
| 74 |
+
</nav>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<nav className="topbar-tabs">
|
| 78 |
+
{TABS.map((tab) => {
|
| 79 |
+
const Icon = tab.icon;
|
| 80 |
+
return (
|
| 81 |
+
<button
|
| 82 |
+
key={tab.id}
|
| 83 |
+
className={`topbar-tab ${active === tab.id ? 'is-active' : ''}`}
|
| 84 |
+
onClick={() => onChange(tab.id)}
|
| 85 |
+
>
|
| 86 |
+
<Icon size={17} strokeWidth={2} />
|
| 87 |
+
<span>{tab.label}</span>
|
| 88 |
+
</button>
|
| 89 |
+
);
|
| 90 |
+
})}
|
| 91 |
+
</nav>
|
| 92 |
+
|
| 93 |
+
<div className="topbar-right">
|
| 94 |
+
<button
|
| 95 |
+
className="topbar-theme-toggle"
|
| 96 |
+
onClick={toggle}
|
| 97 |
+
title={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
| 98 |
+
aria-label="Toggle theme"
|
| 99 |
+
>
|
| 100 |
+
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
| 101 |
+
</button>
|
| 102 |
+
|
| 103 |
+
{/* Tier switcher — demo control to preview Premium vs Basic gating. */}
|
| 104 |
+
{FLAGS.tiers && (
|
| 105 |
+
<div className="topbar-tier" ref={tierRef} onClick={() => setTierOpen((o) => !o)}>
|
| 106 |
+
<span className={`tier-pill tier-${tier}`}>
|
| 107 |
+
<Crown size={13} />
|
| 108 |
+
{tier === 'premium' ? 'Premium' : 'Basic'}
|
| 109 |
+
</span>
|
| 110 |
+
<ChevronDown size={15} />
|
| 111 |
+
{tierOpen && (
|
| 112 |
+
<div className="topbar-menu tier-menu">
|
| 113 |
+
<button
|
| 114 |
+
className="topbar-menu-item"
|
| 115 |
+
onClick={() => { setTier('premium'); setTierOpen(false); }}
|
| 116 |
+
>
|
| 117 |
+
<Crown size={14} /> Premium
|
| 118 |
+
{tier === 'premium' && <Check size={14} className="tier-check" />}
|
| 119 |
+
</button>
|
| 120 |
+
<button
|
| 121 |
+
className="topbar-menu-item"
|
| 122 |
+
onClick={() => { setTier('basic'); setTierOpen(false); }}
|
| 123 |
+
>
|
| 124 |
+
<span style={{ width: 14 }} /> Basic
|
| 125 |
+
{tier === 'basic' && <Check size={14} className="tier-check" />}
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
<div className="topbar-user" ref={userRef} onClick={() => setMenuOpen((o) => !o)}>
|
| 133 |
+
<span className="topbar-avatar">{initials}</span>
|
| 134 |
+
<span className="topbar-username">{username || 'User'}</span>
|
| 135 |
+
<ChevronDown size={16} />
|
| 136 |
+
{menuOpen && (
|
| 137 |
+
<div className="topbar-menu">
|
| 138 |
+
<button className="topbar-menu-item" onClick={logout}>
|
| 139 |
+
<LogOut size={15} />
|
| 140 |
+
Sign out
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</header>
|
| 147 |
+
);
|
| 148 |
+
}
|
demoprep_app/portal/template-tse/src/components/TrialModal.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Professional upgrade prompt shown once (on the 3rd Ask Northwind AI question).
|
| 2 |
+
import { X, Sparkles, Check, ArrowRight } from 'lucide-react';
|
| 3 |
+
import { Northwind_UPGRADE_URL } from '../config';
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
open: boolean;
|
| 7 |
+
remaining: number;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const PREMIUM_FEATURES = [
|
| 12 |
+
'Unlimited Ask Northwind AI questions',
|
| 13 |
+
'Drill-down & underlying-data access',
|
| 14 |
+
'CSV, Excel & PDF exports',
|
| 15 |
+
'Priority capacity & lane insights',
|
| 16 |
+
'Dedicated onboarding & support',
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
export default function TrialModal({ open, remaining, onClose }: Props) {
|
| 20 |
+
if (!open) return null;
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="sl-trial-overlay" onClick={onClose}>
|
| 24 |
+
<div className="sl-trial-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
| 25 |
+
<button className="sl-trial-close" onClick={onClose} aria-label="Close">
|
| 26 |
+
<X size={18} />
|
| 27 |
+
</button>
|
| 28 |
+
|
| 29 |
+
<div className="sl-trial-body">
|
| 30 |
+
<div className="sl-trial-icon">
|
| 31 |
+
<Sparkles size={24} />
|
| 32 |
+
</div>
|
| 33 |
+
<div className="sl-trial-eyebrow">Northwind Premium</div>
|
| 34 |
+
<h2 className="sl-trial-title">Unlock unlimited Northwind AI</h2>
|
| 35 |
+
<p className="sl-trial-sub">
|
| 36 |
+
{remaining > 0 ? (
|
| 37 |
+
<>You have <strong>{remaining}</strong> free question{remaining === 1 ? '' : 's'} left on the trial. </>
|
| 38 |
+
) : (
|
| 39 |
+
<>You’ve reached your free-trial limit. </>
|
| 40 |
+
)}
|
| 41 |
+
Upgrade to Premium for unlimited access across your brokerage.
|
| 42 |
+
</p>
|
| 43 |
+
|
| 44 |
+
<ul className="sl-trial-features">
|
| 45 |
+
{PREMIUM_FEATURES.map((f) => (
|
| 46 |
+
<li key={f}>
|
| 47 |
+
<span className="sl-trial-check"><Check size={14} /></span>
|
| 48 |
+
{f}
|
| 49 |
+
</li>
|
| 50 |
+
))}
|
| 51 |
+
</ul>
|
| 52 |
+
|
| 53 |
+
<a className="sl-trial-cta" href={Northwind_UPGRADE_URL} target="_blank" rel="noopener noreferrer">
|
| 54 |
+
Upgrade to Premium <ArrowRight size={18} />
|
| 55 |
+
</a>
|
| 56 |
+
<button className="sl-trial-dismiss" onClick={onClose}>
|
| 57 |
+
Maybe later
|
| 58 |
+
</button>
|
| 59 |
+
<p className="sl-trial-fine">
|
| 60 |
+
Demo experience — no payment is taken. Talk to your Northwind account team for
|
| 61 |
+
production pricing.
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
demoprep_app/portal/template-tse/src/components/WinBackModal.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { X, Zap, Check, RefreshCw } from 'lucide-react';
|
| 3 |
+
import { SignalContext } from '../lib/signalContext';
|
| 4 |
+
|
| 5 |
+
const CADENCE_OPTIONS = [
|
| 6 |
+
'Win-Back Cadence — High Priority',
|
| 7 |
+
'Re-engagement — 7-day Sprint',
|
| 8 |
+
'Executive Win-Back',
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
open: boolean;
|
| 13 |
+
context: SignalContext | null;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function WinBackModal({ open, context, onClose }: Props) {
|
| 18 |
+
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
| 19 |
+
const [cadence, setCadence] = useState(CADENCE_OPTIONS[0]);
|
| 20 |
+
const [assigned, setAssigned] = useState(false);
|
| 21 |
+
|
| 22 |
+
// Reset selection/state whenever a new signal is opened.
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (context) {
|
| 25 |
+
setSelected(Object.fromEntries(context.contacts.map((c) => [c.id, true])));
|
| 26 |
+
setCadence(CADENCE_OPTIONS[0]);
|
| 27 |
+
setAssigned(false);
|
| 28 |
+
}
|
| 29 |
+
}, [context]);
|
| 30 |
+
|
| 31 |
+
if (!open || !context) return null;
|
| 32 |
+
|
| 33 |
+
const count = context.contacts.filter((c) => selected[c.id]).length;
|
| 34 |
+
const otherFields = context.fields.filter(
|
| 35 |
+
(f) => !/cadence|account|company|organization/i.test(f.name),
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="winback-overlay" onClick={onClose}>
|
| 40 |
+
<div className="winback-modal" onClick={(e) => e.stopPropagation()}>
|
| 41 |
+
<div className="winback-header">
|
| 42 |
+
<div className="winback-title">
|
| 43 |
+
<Zap size={18} />
|
| 44 |
+
<span>Re-engage Cadence</span>
|
| 45 |
+
</div>
|
| 46 |
+
<button className="winback-close" onClick={onClose} aria-label="Close">
|
| 47 |
+
<X size={18} />
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{assigned ? (
|
| 52 |
+
<div className="winback-success">
|
| 53 |
+
<div className="winback-success-icon">
|
| 54 |
+
<Check size={30} />
|
| 55 |
+
</div>
|
| 56 |
+
<h3>{count} contact{count === 1 ? '' : 's'} enrolled</h3>
|
| 57 |
+
<p>
|
| 58 |
+
{context.account ? <strong>{context.account}</strong> : 'These contacts'}{' '}
|
| 59 |
+
{count === 1 ? 'has' : 'have'} been added to <strong>{cadence}</strong>.
|
| 60 |
+
</p>
|
| 61 |
+
<button className="winback-btn-primary" onClick={onClose}>
|
| 62 |
+
Done
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
) : (
|
| 66 |
+
<>
|
| 67 |
+
<div className="winback-body">
|
| 68 |
+
<p className="winback-lede">
|
| 69 |
+
Assign these contacts to a high-priority Win-Back Cadence to re-engage
|
| 70 |
+
this account in one click.
|
| 71 |
+
</p>
|
| 72 |
+
|
| 73 |
+
<div className="winback-context">
|
| 74 |
+
{context.cadence && (
|
| 75 |
+
<div className="winback-ctx-item">
|
| 76 |
+
<span className="winback-ctx-label">Source cadence</span>
|
| 77 |
+
<span className="winback-ctx-value">{context.cadence}</span>
|
| 78 |
+
</div>
|
| 79 |
+
)}
|
| 80 |
+
{context.account && (
|
| 81 |
+
<div className="winback-ctx-item">
|
| 82 |
+
<span className="winback-ctx-label">Account</span>
|
| 83 |
+
<span className="winback-ctx-value">{context.account}</span>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{otherFields.length > 0 && (
|
| 89 |
+
<div className="winback-fields">
|
| 90 |
+
{otherFields.map((f) => (
|
| 91 |
+
<span className="winback-chip" key={f.name}>
|
| 92 |
+
<span className="winback-chip-k">{f.name}</span>
|
| 93 |
+
<span className="winback-chip-v">{f.value}</span>
|
| 94 |
+
</span>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
|
| 99 |
+
<div className="winback-section-title">
|
| 100 |
+
Contacts to enroll ({count})
|
| 101 |
+
</div>
|
| 102 |
+
<div className="winback-contacts">
|
| 103 |
+
{context.contacts.map((c) => (
|
| 104 |
+
<label className="winback-contact" key={c.id}>
|
| 105 |
+
<input
|
| 106 |
+
type="checkbox"
|
| 107 |
+
checked={!!selected[c.id]}
|
| 108 |
+
onChange={(e) =>
|
| 109 |
+
setSelected((s) => ({ ...s, [c.id]: e.target.checked }))
|
| 110 |
+
}
|
| 111 |
+
/>
|
| 112 |
+
<span className="winback-avatar">
|
| 113 |
+
{c.name.slice(0, 1).toUpperCase()}
|
| 114 |
+
</span>
|
| 115 |
+
<span className="winback-contact-info">
|
| 116 |
+
<span className="winback-contact-name">{c.name}</span>
|
| 117 |
+
<span className="winback-contact-meta">
|
| 118 |
+
{c.title}
|
| 119 |
+
{c.email ? ` · ${c.email}` : ''}
|
| 120 |
+
</span>
|
| 121 |
+
</span>
|
| 122 |
+
</label>
|
| 123 |
+
))}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div className="winback-field">
|
| 127 |
+
<label className="winback-label">Assign to cadence</label>
|
| 128 |
+
<select
|
| 129 |
+
className="winback-select"
|
| 130 |
+
value={cadence}
|
| 131 |
+
onChange={(e) => setCadence(e.target.value)}
|
| 132 |
+
>
|
| 133 |
+
{CADENCE_OPTIONS.map((c) => (
|
| 134 |
+
<option key={c}>{c}</option>
|
| 135 |
+
))}
|
| 136 |
+
</select>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div className="winback-footer">
|
| 141 |
+
<button className="winback-btn-secondary" onClick={onClose}>
|
| 142 |
+
Cancel
|
| 143 |
+
</button>
|
| 144 |
+
<button
|
| 145 |
+
className="winback-btn-primary"
|
| 146 |
+
disabled={count === 0}
|
| 147 |
+
onClick={() => setAssigned(true)}
|
| 148 |
+
>
|
| 149 |
+
<RefreshCw size={16} />
|
| 150 |
+
Assign to Win-Back Cadence
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
demoprep_app/portal/template-tse/src/config.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CONTENT } from './content';
|
| 2 |
+
// ---------------------------------------------------------------------------
|
| 3 |
+
// Central configuration for the Northwind Analytics mockup.
|
| 4 |
+
// IDs + ThoughtSpot styling are sourced from northwind_fin.json so that every
|
| 5 |
+
// embedded ThoughtSpot component shares the exact same look & feel.
|
| 6 |
+
// ---------------------------------------------------------------------------
|
| 7 |
+
|
| 8 |
+
/** ThoughtSpot cluster (from appConfig.thoughtspotUrl) */
|
| 9 |
+
export const THOUGHTSPOT_HOST = 'https://YOUR-CLUSTER.thoughtspot.cloud';
|
| 10 |
+
|
| 11 |
+
/** The Analytics liveboard embedded in Tab 2 (Analytics). */
|
| 12 |
+
export const ANALYTICS_LIVEBOARD_ID = 'REPLACE_MAIN_LIVEBOARD_GUID';
|
| 13 |
+
|
| 14 |
+
/** Liveboard embedded inline by "View insights" on a process, filtered by the
|
| 15 |
+
* process name (no vizId — the whole liveboard is shown, stripped of chrome). */
|
| 16 |
+
export const INLINE_INSIGHTS_LIVEBOARD_ID =
|
| 17 |
+
'REPLACE_INLINE_LIVEBOARD_GUID';
|
| 18 |
+
|
| 19 |
+
/** Worksheet/model the Processes tab queries for process names + metrics. */
|
| 20 |
+
export const CADENCE_WORKSHEET_ID = 'REPLACE_MODEL_GUID';
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Capacity tab embeds a single VISUALIZATION (LiveboardEmbed + liveboardId +
|
| 24 |
+
* vizId) with a code-based custom action.
|
| 25 |
+
*/
|
| 26 |
+
export const CAPACITY_LIVEBOARD_ID = 'REPLACE_ACTION_LIVEBOARD_GUID';
|
| 27 |
+
export const CAPACITY_VIZ_ID = 'REPLACE_ACTION_VIZ_GUID';
|
| 28 |
+
|
| 29 |
+
/** Code-based custom action ("Request Bid") on the Capacity viz context menu. */
|
| 30 |
+
export const REQUEST_BID_ACTION_ID = 'request-bid';
|
| 31 |
+
export const REQUEST_BID_ACTION_NAME = 'Request Bid';
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Candidate column names for "cadence" on the worksheet. The first one that
|
| 35 |
+
* returns data is used both to list cadence names and as the runtime-filter
|
| 36 |
+
* column name on the inline liveboard.
|
| 37 |
+
*/
|
| 38 |
+
export const CADENCE_COLUMN_CANDIDATES = [
|
| 39 |
+
'Dim Carrier Name',
|
| 40 |
+
'Carrier Name',
|
| 41 |
+
'Carrier',
|
| 42 |
+
'Dim Carrier',
|
| 43 |
+
];
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Candidate column names for the per-cadence detail metrics shown next to each
|
| 47 |
+
* cadence in the Inline Insights UI. The first candidate that returns data for
|
| 48 |
+
* each metric is used; metrics with no match are simply omitted.
|
| 49 |
+
*/
|
| 50 |
+
export const CADENCE_DETAIL_COLUMNS: Record<
|
| 51 |
+
'loadsDelivered' | 'loadsOnTime' | 'carrierSize',
|
| 52 |
+
{ label: string; format: 'text' | 'date' | 'number' | 'currency'; candidates: string[] }
|
| 53 |
+
> = {
|
| 54 |
+
loadsDelivered: {
|
| 55 |
+
label: 'Loads Delivered',
|
| 56 |
+
format: 'number',
|
| 57 |
+
candidates: ['Loads Delivered', 'Delivered Loads', 'Total Loads Delivered', 'Loads'],
|
| 58 |
+
},
|
| 59 |
+
loadsOnTime: {
|
| 60 |
+
label: 'Loads On Time',
|
| 61 |
+
format: 'number',
|
| 62 |
+
candidates: ['Loads On Time', 'On-Time Loads', 'On Time Loads', 'On Time Deliveries'],
|
| 63 |
+
},
|
| 64 |
+
carrierSize: {
|
| 65 |
+
label: 'Carrier Size',
|
| 66 |
+
format: 'text',
|
| 67 |
+
candidates: ['Carrier Size', 'Fleet Size', 'Size', 'Carrier Tier'],
|
| 68 |
+
},
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Worksheet / model used everywhere Spotter runs — the "Northwind AI" Spotter
|
| 73 |
+
* modal (Analytics tab), the Ask Northwind Spotter embed, and the host chatbot.
|
| 74 |
+
*/
|
| 75 |
+
export const WORKSHEET_ID = 'REPLACE_MODEL_GUID';
|
| 76 |
+
|
| 77 |
+
/** Carriers list is not org-scoped for Northwind. */
|
| 78 |
+
export const Northwind_ORG_ID = '';
|
| 79 |
+
export const ORG_COLUMN_CANDIDATES: string[] = [];
|
| 80 |
+
|
| 81 |
+
/** Analytics-tab filters — Region + Freight corridor (multiselect), both empty
|
| 82 |
+
* by default (= no runtime filter / show all). Load Date is a date-range filter
|
| 83 |
+
* driven by DATE_COLUMN below. Candidate column names, first match wins. */
|
| 84 |
+
export const REGION_COLUMN_CANDIDATES = [
|
| 85 |
+
'Region',
|
| 86 |
+
'Dim Region',
|
| 87 |
+
'Sales Region',
|
| 88 |
+
'Geo Region',
|
| 89 |
+
];
|
| 90 |
+
export const CORRIDOR_COLUMN_CANDIDATES = [
|
| 91 |
+
'Freight Corridor',
|
| 92 |
+
'Corridor',
|
| 93 |
+
'Lane',
|
| 94 |
+
'Freight Lane',
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
// ---------------------------------------------------------------------------
|
| 98 |
+
// ThoughtSpot embed styling — taken verbatim from
|
| 99 |
+
// northwind_fin.json > stylingConfig.embeddedContent
|
| 100 |
+
// ---------------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
/** CSS variables applied to every embedded ThoughtSpot surface. */
|
| 103 |
+
// Embed font — the family the ThoughtSpot iframes request. Overwritten by the
|
| 104 |
+
// codemod from spec.appFont; pair it with TS_FONT_URL (below), which loads that
|
| 105 |
+
// font INTO the iframe (a Google-Fonts URL, or a self-hosted /embed-font.css).
|
| 106 |
+
export const EMBED_FONT_FAMILY = '"Inter", system-ui, "Segoe UI", sans-serif';
|
| 107 |
+
|
| 108 |
+
export const TS_CSS_VARIABLES: Record<string, string> = {
|
| 109 |
+
'--ts-var-root-background': '#FCFCFD',
|
| 110 |
+
'--ts-var-root-color': '#182338',
|
| 111 |
+
'--ts-var-root-font-family': EMBED_FONT_FAMILY,
|
| 112 |
+
'--ts-var-application-color': '#26324e',
|
| 113 |
+
'--ts-var-nav-background': '#FFFFFF',
|
| 114 |
+
'--ts-var-nav-color': '#26324e',
|
| 115 |
+
'--ts-var-search-data-button-background': '#4F5BD5',
|
| 116 |
+
'--ts-var-search-data-button-font-color': '#FFFFFF',
|
| 117 |
+
'--ts-var-search-bar-background': '#F5F6F8',
|
| 118 |
+
'--ts-var-search-bar-text-font-color': '#26324e',
|
| 119 |
+
'--ts-var-button-border-radius': '10px',
|
| 120 |
+
'--ts-var-button--icon-border-radius': '8px',
|
| 121 |
+
'--ts-var-button--primary-background': '#4F5BD5',
|
| 122 |
+
'--ts-var-button--primary-color': '#FFFFFF',
|
| 123 |
+
'--ts-var-button--primary--hover-background': '#3F49B8',
|
| 124 |
+
'--ts-var-button--primary--active-background': '#333C99',
|
| 125 |
+
'--ts-var-button--secondary-background': '#EEF0F4',
|
| 126 |
+
'--ts-var-button--secondary-color': '#26324e',
|
| 127 |
+
// See dark-theme note: the SDK reads the typo'd "--hovers-background".
|
| 128 |
+
'--ts-var-button--secondary--hover-background': '#E3E6EC',
|
| 129 |
+
'--ts-var-button--secondary--hovers-background': '#E3E6EC',
|
| 130 |
+
'--ts-var-button--secondary--active-background': '#D7DBE3',
|
| 131 |
+
'--ts-var-viz-title-color': '#182338',
|
| 132 |
+
'--ts-var-viz-title-font-family': EMBED_FONT_FAMILY,
|
| 133 |
+
'--ts-var-viz-description-color': '#3a4a6b',
|
| 134 |
+
'--ts-var-viz-border-radius': '18px',
|
| 135 |
+
'--ts-var-viz-box-shadow': '0 1px 2px rgba(7, 59, 58, 0.06), 0 16px 44px rgba(7, 59, 58, 0.16)',
|
| 136 |
+
'--ts-var-viz-background': '#FFFFFF',
|
| 137 |
+
'--ts-var-chip-border-radius': '999px',
|
| 138 |
+
'--ts-var-chip-background': '#EEF0F5',
|
| 139 |
+
'--ts-var-chip-color': '#26324e',
|
| 140 |
+
'--ts-var-chip--hover-background': '#E3E6EC',
|
| 141 |
+
'--ts-var-chip--hover-color': '#182338',
|
| 142 |
+
'--ts-var-chip--active-background': '#4F5BD5',
|
| 143 |
+
'--ts-var-chip--active-color': '#FFFFFF',
|
| 144 |
+
'--ts-var-menu-background': '#FFFFFF',
|
| 145 |
+
'--ts-var-menu-color': '#26324e',
|
| 146 |
+
'--ts-var-menu--hover-background': '#F1F2F6',
|
| 147 |
+
'--ts-var-dialog-body-background': '#FFFFFF',
|
| 148 |
+
'--ts-var-dialog-body-color': '#26324e',
|
| 149 |
+
'--ts-var-dialog-header-background': '#FFFFFF',
|
| 150 |
+
'--ts-var-dialog-header-color': '#182338',
|
| 151 |
+
'--ts-var-list-hover-background': '#F1F2F6',
|
| 152 |
+
'--ts-var-list-selected-background': '#E8EAF1',
|
| 153 |
+
'--ts-var-liveboard-layout-background': '#FCFCFD',
|
| 154 |
+
'--ts-var-liveboard-header-background': '#FFFFFF',
|
| 155 |
+
'--ts-var-liveboard-header-font-color': '#182338',
|
| 156 |
+
'--ts-var-liveboard-tile-background': '#FFFFFF',
|
| 157 |
+
'--ts-var-liveboard-tile-border-color': '#E2E4EC',
|
| 158 |
+
'--ts-var-liveboard-tile-border-radius': '18px',
|
| 159 |
+
'--ts-var-liveboard-tile-padding': '12px',
|
| 160 |
+
'--ts-var-liveboard-tile-table-header-background': '#F5F6F9',
|
| 161 |
+
'--ts-var-liveboard-tab-active-border-color': '#4F5BD5',
|
| 162 |
+
'--ts-var-liveboard-tab-hover-color': '#4F5BD5',
|
| 163 |
+
'--ts-var-liveboard-header-action-button-background': '#EEF0F5',
|
| 164 |
+
'--ts-var-liveboard-header-action-button-font-color': '#26324e',
|
| 165 |
+
// NOTE: despite "-color", these are the button's hover/active BACKGROUND.
|
| 166 |
+
// Label is dark here, so keep the hover background LIGHT for contrast.
|
| 167 |
+
'--ts-var-liveboard-header-action-button-hover-color': '#E3E6EC',
|
| 168 |
+
'--ts-var-liveboard-header-action-button-active-color': '#D7DBE3',
|
| 169 |
+
'--ts-var-parameter-chip-background': '#EEF0F5',
|
| 170 |
+
'--ts-var-parameter-chip-text-color': '#26324e',
|
| 171 |
+
'--ts-var-parameter-chip-hover-background': '#E3E6EC',
|
| 172 |
+
'--ts-var-parameter-chip-hover-text-color': '#182338',
|
| 173 |
+
'--ts-var-parameter-chip-active-background': '#4F5BD5',
|
| 174 |
+
'--ts-var-parameter-chip-active-text-color': '#FFFFFF',
|
| 175 |
+
'--ts-var-axis-title-color': '#3a4a6b',
|
| 176 |
+
'--ts-var-axis-data-label-color': '#5A6A7E',
|
| 177 |
+
'--ts-var-kpi-hero-color': '#182338',
|
| 178 |
+
'--ts-var-kpi-comparison-color': '#3a4a6b',
|
| 179 |
+
'--ts-var-kpi-positive-change-color': '#1E8E5A',
|
| 180 |
+
'--ts-var-kpi-negative-change-color': '#C84B3A',
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
export type ThemeName = 'light' | 'dark';
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* Dark counterpart to TS_CSS_VARIABLES — a deep-evergreen dark theme that keeps
|
| 187 |
+
* Northwind's green/coral accents. Passed per-embed for the active theme so the
|
| 188 |
+
* embedded Liveboard / Search / Spotter track the host app's light/dark toggle.
|
| 189 |
+
* CSS variables: https://developers.thoughtspot.com/docs/css-variables-reference
|
| 190 |
+
*/
|
| 191 |
+
export const TS_VARS_DARK: Record<string, string> = {
|
| 192 |
+
'--ts-var-root-background': '#0b0d10',
|
| 193 |
+
'--ts-var-root-color': '#e7ecf2',
|
| 194 |
+
'--ts-var-root-secondary-color': '#93a0b4',
|
| 195 |
+
'--ts-var-root-font-family': EMBED_FONT_FAMILY,
|
| 196 |
+
'--ts-var-application-color': '#e7ecf2',
|
| 197 |
+
'--ts-var-nav-background': '#0b0d10',
|
| 198 |
+
'--ts-var-nav-color': '#e7ecf2',
|
| 199 |
+
'--ts-var-search-data-button-background': '#4F5BD5',
|
| 200 |
+
'--ts-var-search-data-button-font-color': '#FFFFFF',
|
| 201 |
+
'--ts-var-search-bar-background': '#14181f',
|
| 202 |
+
'--ts-var-search-bar-text-font-color': '#e7ecf2',
|
| 203 |
+
'--ts-var-search-auto-complete-background': '#14181f',
|
| 204 |
+
'--ts-var-search-auto-complete-font-color': '#e7ecf2',
|
| 205 |
+
'--ts-var-button-border-radius': '10px',
|
| 206 |
+
'--ts-var-button--icon-border-radius': '8px',
|
| 207 |
+
// Deeper green + white text so labels stay readable (incl. on hover).
|
| 208 |
+
'--ts-var-button--primary-background': '#4F5BD5',
|
| 209 |
+
'--ts-var-button--primary-color': '#ffffff',
|
| 210 |
+
'--ts-var-button--primary--hover-background': '#3F49B8',
|
| 211 |
+
'--ts-var-button--primary--active-background': '#333C99',
|
| 212 |
+
'--ts-var-button--secondary-background': '#1a212b',
|
| 213 |
+
'--ts-var-button--secondary-color': '#e7ecf2',
|
| 214 |
+
// NOTE: the SDK's *effective* var name is the typo'd "--hovers-background"
|
| 215 |
+
// (default #aac2f8, a light blue) — the clean "--hover-background" is only in
|
| 216 |
+
// the docs. Set both so the label stays readable on hover.
|
| 217 |
+
'--ts-var-button--secondary--hover-background': '#4F5BD5',
|
| 218 |
+
'--ts-var-button--secondary--hovers-background': '#4F5BD5',
|
| 219 |
+
'--ts-var-button--secondary--active-background': '#3F49B8',
|
| 220 |
+
'--ts-var-button--tertiary-background': 'transparent',
|
| 221 |
+
'--ts-var-button--tertiary-color': '#c3cbd6',
|
| 222 |
+
'--ts-var-button--tertiary--hover-background': '#1a212b',
|
| 223 |
+
'--ts-var-viz-title-color': '#e7ecf2',
|
| 224 |
+
'--ts-var-viz-title-font-family': EMBED_FONT_FAMILY,
|
| 225 |
+
'--ts-var-viz-description-color': '#93a0b4',
|
| 226 |
+
'--ts-var-viz-border-radius': '18px',
|
| 227 |
+
'--ts-var-viz-box-shadow':
|
| 228 |
+
'0 1px 0 rgba(255,255,255,0.05) inset, 0 14px 44px rgba(0,0,0,0.55), 0 0 0 1px rgba(45,164,191,0.14)',
|
| 229 |
+
'--ts-var-viz-background': '#14181f',
|
| 230 |
+
'--ts-var-viz-legend-hover-background': '#1a212b',
|
| 231 |
+
'--ts-var-chip-border-radius': '999px',
|
| 232 |
+
'--ts-var-chip-background': '#1a212b',
|
| 233 |
+
'--ts-var-chip-color': '#c3cbd6',
|
| 234 |
+
'--ts-var-chip--hover-background': '#232a34',
|
| 235 |
+
'--ts-var-chip--hover-color': '#e7ecf2',
|
| 236 |
+
'--ts-var-chip--active-background': '#4F5BD5',
|
| 237 |
+
'--ts-var-chip--active-color': '#FFFFFF',
|
| 238 |
+
'--ts-var-menu-background': '#14181f',
|
| 239 |
+
'--ts-var-menu-color': '#e7ecf2',
|
| 240 |
+
'--ts-var-menu--hover-background': '#1a212b',
|
| 241 |
+
'--ts-var-menu-selected-text-color': '#4F5BD5',
|
| 242 |
+
'--ts-var-menu-separator-background': '#232a34',
|
| 243 |
+
'--ts-var-dialog-body-background': '#14181f',
|
| 244 |
+
'--ts-var-dialog-body-color': '#e7ecf2',
|
| 245 |
+
'--ts-var-dialog-header-background': '#14181f',
|
| 246 |
+
'--ts-var-dialog-header-color': '#e7ecf2',
|
| 247 |
+
'--ts-var-dialog-footer-background': '#14181f',
|
| 248 |
+
'--ts-var-list-hover-background': '#1a212b',
|
| 249 |
+
'--ts-var-list-selected-background': '#232a34',
|
| 250 |
+
'--ts-var-liveboard-layout-background': '#0b0d10',
|
| 251 |
+
'--ts-var-liveboard-header-background': '#0b0d10',
|
| 252 |
+
'--ts-var-liveboard-header-font-color': '#e7ecf2',
|
| 253 |
+
'--ts-var-liveboard-edit-bar-background': '#14181f',
|
| 254 |
+
'--ts-var-liveboard-cross-filter-layout-background': '#14181f',
|
| 255 |
+
'--ts-var-liveboard-tile-background': '#14181f',
|
| 256 |
+
'--ts-var-liveboard-tile-border-color': '#232a34',
|
| 257 |
+
'--ts-var-liveboard-tile-border-radius': '18px',
|
| 258 |
+
'--ts-var-liveboard-tile-padding': '12px',
|
| 259 |
+
'--ts-var-liveboard-tile-table-header-background': '#1a212b',
|
| 260 |
+
'--ts-var-liveboard-tab-active-border-color': '#4F5BD5',
|
| 261 |
+
'--ts-var-liveboard-tab-hover-color': '#4F5BD5',
|
| 262 |
+
'--ts-var-liveboard-header-action-button-background': '#1a212b',
|
| 263 |
+
'--ts-var-liveboard-header-action-button-font-color': '#e7ecf2',
|
| 264 |
+
// NOTE: despite "-color", these are the button's hover/active BACKGROUND.
|
| 265 |
+
// Label is light here, so keep the hover background DARK green for contrast.
|
| 266 |
+
'--ts-var-liveboard-header-action-button-hover-color': '#4F5BD5',
|
| 267 |
+
'--ts-var-liveboard-header-action-button-active-color': '#3F49B8',
|
| 268 |
+
// Masterpieces grouping/styling (active because isLiveboardMasterpiecesEnabled)
|
| 269 |
+
'--ts-var-liveboard-group-background': '#0b0d10',
|
| 270 |
+
'--ts-var-liveboard-group-title-font-color': '#e7ecf2',
|
| 271 |
+
'--ts-var-liveboard-group-border-color': '#232a34',
|
| 272 |
+
'--ts-var-liveboard-group-description-font-color': '#93a0b4',
|
| 273 |
+
'--ts-var-liveboard-group-tile-title-font-color': '#e7ecf2',
|
| 274 |
+
'--ts-var-liveboard-group-tile-description-font-color': '#93a0b4',
|
| 275 |
+
'--ts-var-parameter-chip-background': '#1a212b',
|
| 276 |
+
'--ts-var-parameter-chip-text-color': '#e7ecf2',
|
| 277 |
+
'--ts-var-parameter-chip-hover-background': '#232a34',
|
| 278 |
+
'--ts-var-parameter-chip-hover-text-color': '#ffffff',
|
| 279 |
+
'--ts-var-parameter-chip-active-background': '#4F5BD5',
|
| 280 |
+
'--ts-var-parameter-chip-active-text-color': '#FFFFFF',
|
| 281 |
+
'--ts-var-axis-title-color': '#93a0b4',
|
| 282 |
+
'--ts-var-axis-data-label-color': '#93a0b4',
|
| 283 |
+
'--ts-var-answer-data-panel-background-color': '#0b0d10',
|
| 284 |
+
'--ts-var-answer-edit-panel-background-color': '#0b0d10',
|
| 285 |
+
'--ts-var-kpi-hero-color': '#e7ecf2',
|
| 286 |
+
'--ts-var-kpi-comparison-color': '#93a0b4',
|
| 287 |
+
'--ts-var-kpi-positive-change-color': '#35c98a',
|
| 288 |
+
'--ts-var-kpi-negative-change-color': '#ff7a6b',
|
| 289 |
+
// Spotter / Sage conversational surfaces
|
| 290 |
+
'--ts-var-spotter-input-background': '#14181f',
|
| 291 |
+
'--ts-var-spotter-prompt-background': '#1a212b',
|
| 292 |
+
'--ts-var-sage-embed-background-color': '#0b0d10',
|
| 293 |
+
'--ts-var-sage-bar-header-background-color': '#0b0d10',
|
| 294 |
+
'--ts-var-sage-search-box-background-color': '#14181f',
|
| 295 |
+
'--ts-var-sage-search-box-font-color': '#e7ecf2',
|
| 296 |
+
'--ts-var-source-selector-background-color': '#14181f',
|
| 297 |
+
'--ts-var-sage-seed-questions-background': '#14181f',
|
| 298 |
+
'--ts-var-sage-seed-questions-font-color': '#c3cbd6',
|
| 299 |
+
'--ts-var-sage-seed-questions-hover-background': '#1a212b',
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
+
/** Return the CSS-variable set for the active theme. */
|
| 303 |
+
export function tsVarsFor(theme: ThemeName): Record<string, string> {
|
| 304 |
+
return theme === 'dark' ? TS_VARS_DARK : TS_CSS_VARIABLES;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/**
|
| 308 |
+
* Dark-only color overrides for surfaces with no dedicated CSS variable (answer
|
| 309 |
+
* cards, floating chrome), plus the KPI headline in the Northwind green. Applied
|
| 310 |
+
* via rules_UNSTABLE only in dark mode.
|
| 311 |
+
* Docs: https://developers.thoughtspot.com/docs/css-rules
|
| 312 |
+
*/
|
| 313 |
+
const DARK_SURFACE = '#14181f';
|
| 314 |
+
const DARK_INK = '#e7ecf2';
|
| 315 |
+
const surfaceRule = { 'background-color': `${DARK_SURFACE} !important`, color: `${DARK_INK} !important` };
|
| 316 |
+
export const TS_RULES_DARK: Record<string, Record<string, string>> = {
|
| 317 |
+
// Text inputs / textareas / field wrappers.
|
| 318 |
+
[[
|
| 319 |
+
'input:not([type="checkbox"]):not([type="radio"])',
|
| 320 |
+
'textarea',
|
| 321 |
+
'[class*="input" i]',
|
| 322 |
+
'[class*="field" i]',
|
| 323 |
+
'[class*="searchbox" i]',
|
| 324 |
+
].join(',')]: { 'background-color': '#0b1f19 !important', color: `${DARK_INK} !important` },
|
| 325 |
+
// Segmented controls / toggles / switchers (e.g. Quick Search | Deep Analysis).
|
| 326 |
+
[[
|
| 327 |
+
'[class*="toggle" i]',
|
| 328 |
+
'[class*="switch" i]',
|
| 329 |
+
'[class*="switcher" i]',
|
| 330 |
+
'[class*="segment" i]',
|
| 331 |
+
].join(',')]: surfaceRule,
|
| 332 |
+
// Dropdown / select header (dimension & filter pickers).
|
| 333 |
+
'[data-testid="select-dropdown-header"]': { 'background-color': '#0b1f19 !important', color: `${DARK_INK} !important` },
|
| 334 |
+
// Answer / conversation / Spotter / Sage surfaces + the conversations sidebar.
|
| 335 |
+
[[
|
| 336 |
+
'[class*="answer" i]',
|
| 337 |
+
'[class*="conversation" i]',
|
| 338 |
+
'[class*="message" i]',
|
| 339 |
+
'[class*="spotter" i]',
|
| 340 |
+
'[class*="sage" i]',
|
| 341 |
+
'[class*="sidebar" i]',
|
| 342 |
+
'[class*="history" i]',
|
| 343 |
+
].join(',')]: surfaceRule,
|
| 344 |
+
// Floating chrome — panels / modals / menus / footers.
|
| 345 |
+
[[
|
| 346 |
+
'[class*="modal" i]',
|
| 347 |
+
'[class*="dialog" i]',
|
| 348 |
+
'[class*="popover" i]',
|
| 349 |
+
'[class*="flyout" i]',
|
| 350 |
+
'[class*="drawer" i]',
|
| 351 |
+
'[class*="data-panel" i]',
|
| 352 |
+
'[class*="dataPanel" i]',
|
| 353 |
+
'[class*="footer" i]',
|
| 354 |
+
].join(',')]: surfaceRule,
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
/**
|
| 358 |
+
* Welcome message for the host-owned Northwind AI chatbot
|
| 359 |
+
* (from northwind_fin.json > appConfig.chatbot.welcomeMessage, lightly adapted).
|
| 360 |
+
*/
|
| 361 |
+
export const CHATBOT_WELCOME = CONTENT.chatbot.welcome;
|
| 362 |
+
|
| 363 |
+
/** Greeting + data model used when the chatbot is opened on the Carriers tab. */
|
| 364 |
+
export const CHATBOT_CADENCES_WELCOME =
|
| 365 |
+
'What would you like to know about the carriers shown here?';
|
| 366 |
+
|
| 367 |
+
/** Spotter icon sprite (magician/spotter icon) from northwind_fin.json. */
|
| 368 |
+
export const TS_ICON_SPRITE_URL =
|
| 369 |
+
'https://cdn.jsdelivr.net/gh/thoughtspot/tse-demo-builders-pre-built/icons/spotter/generic-02.svg';
|
| 370 |
+
|
| 371 |
+
/**
|
| 372 |
+
* String customizations — replace "Spotter" with "Northwind AI" everywhere,
|
| 373 |
+
* plus the landing-page description override (from
|
| 374 |
+
* northwind_fin.json > stylingConfig.embeddedContent.strings / stringIDs).
|
| 375 |
+
*/
|
| 376 |
+
export const TS_STRINGS: Record<string, string> = {
|
| 377 |
+
Spotter: CONTENT.aiName,
|
| 378 |
+
};
|
| 379 |
+
|
| 380 |
+
export const TS_STRING_IDS: Record<string, string> = {
|
| 381 |
+
'convAssist.landingpage.description2': 'Ask a question about sales.',
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
// ---------------------------------------------------------------------------
|
| 385 |
+
// Embed flags — from northwind_fin.json > stylingConfig.embedFlags
|
| 386 |
+
// ---------------------------------------------------------------------------
|
| 387 |
+
|
| 388 |
+
export const LIVEBOARD_EMBED_FLAGS = {
|
| 389 |
+
enable2ColumnLayout: true,
|
| 390 |
+
isLiveboardStylingAndGroupingEnabled: true,
|
| 391 |
+
// Masterpiece layout — richer KPI/headline tile styling & grouping.
|
| 392 |
+
// https://developers.thoughtspot.com/docs/Interface_LiveboardViewConfig#_isliveboardmasterpiecesenabled
|
| 393 |
+
isLiveboardMasterpiecesEnabled: true,
|
| 394 |
+
};
|
| 395 |
+
|
| 396 |
+
export const SPOTTER_EMBED_FLAGS = {
|
| 397 |
+
updatedSpotterChatPrompt: true,
|
| 398 |
+
spotterSidebarConfig: {
|
| 399 |
+
enablePastConversationsSidebar: true,
|
| 400 |
+
spotterSidebarTitle: 'My Conversations',
|
| 401 |
+
// Start collapsed so the answer canvas gets full width when Spotter opens.
|
| 402 |
+
spotterSidebarDefaultExpanded: false,
|
| 403 |
+
},
|
| 404 |
+
};
|
| 405 |
+
|
| 406 |
+
/**
|
| 407 |
+
* Extra customCSS rules injected into every embed so ThoughtSpot's own buttons
|
| 408 |
+
* carry the Northwind blue↔green gradient and the KPI (headline) tiles pop with the
|
| 409 |
+
* brand gradient. ThoughtSpot class names are version-dependent — these use
|
| 410 |
+
* broad candidate selectors; if a surface isn't picked up, Inspect the iframe
|
| 411 |
+
* and tighten the selector to the real class name.
|
| 412 |
+
*/
|
| 413 |
+
export const Northwind_EMBED_RULES: Record<string, Record<string, string>> = {
|
| 414 |
+
// No gradient on KPI tiles / viz titles by default — embeds use ThoughtSpot's
|
| 415 |
+
// native styling, which already follows the theme via the CSS variables above.
|
| 416 |
+
// Only brand the AI / Spotter header button (an off-brand green pill on some
|
| 417 |
+
// clusters) with a SOLID color. To opt into brand gradients on KPIs/titles,
|
| 418 |
+
// add background linear-gradient rules here (buttons must stay solid).
|
| 419 |
+
'[class*="aiHighlight" i], [class*="ai-highlight" i], [class*="AIHighlights" i], [data-testid*="AIHighlight" i], [class*="spotterLaunch" i], [class*="spotter-button" i], [class*="spotter" i][class*="btn" i], [class*="spotter" i][class*="button" i], [class*="spotter" i][class*="pill" i], [class*="spotter" i][class*="nudge" i], [class*="sage-button" i], [class*="sage" i][class*="btn" i], [class*="sage" i][class*="button" i], [class*="sage" i][class*="pill" i], [class*="askSage" i], [class*="ask-sage" i], [class*="nudge" i]':
|
| 420 |
+
{
|
| 421 |
+
background: '#4F5BD5 !important',
|
| 422 |
+
'background-image': 'none !important',
|
| 423 |
+
color: '#ffffff !important',
|
| 424 |
+
'border-color': 'transparent !important',
|
| 425 |
+
},
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
/** Google-Fonts stylesheet loaded INTO embed iframes so embedded ThoughtSpot
|
| 429 |
+
* surfaces render in the same faces as the host UI (Plus Jakarta Sans for text,
|
| 430 |
+
* Fraunces for titles) — iframes can't see the host page's fonts. customCSSUrl.
|
| 431 |
+
*/
|
| 432 |
+
export const TS_FONT_URL =
|
| 433 |
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
|
| 434 |
+
|
| 435 |
+
/** "Watch video" link shown in the Northwind AI pane. */
|
| 436 |
+
export const Northwind_VIDEO_URL = 'https://www.northwind.com/platform/conversations';
|
| 437 |
+
|
| 438 |
+
/** Sample questions shown on the custom Northwind AI landing pane. */
|
| 439 |
+
export const Northwind_SAMPLE_QUESTIONS = [...CONTENT.sampleQuestions];
|
| 440 |
+
|
| 441 |
+
/** Trial / upgrade prompt shown in the Northwind AI pane after the 2nd query. */
|
| 442 |
+
export const Northwind_TRIAL_QUESTIONS = 20;
|
| 443 |
+
export const TRIAL_TRIGGER = CONTENT.trialQuestionTrigger;
|
| 444 |
+
export const Northwind_UPGRADE_URL = CONTENT.website + '/pricing';
|
| 445 |
+
|
| 446 |
+
/**
|
| 447 |
+
* Hide the Spotter embed's own composer / input bar on the Northwind AI screen
|
| 448 |
+
* — questions are driven from the host-side pane on the right, so the in-embed
|
| 449 |
+
* "Enter your question" box (and its Quick/Deep toggle) is redundant. Injected
|
| 450 |
+
* via rules_UNSTABLE. Class names are version-fragile; verify via Inspect if a
|
| 451 |
+
* release renames them.
|
| 452 |
+
*/
|
| 453 |
+
const HIDE = { display: 'none !important' };
|
| 454 |
+
export const HIDE_SPOTTER_INPUT_RULES: Record<string, Record<string, string>> = {
|
| 455 |
+
'[class*="composer" i]': HIDE,
|
| 456 |
+
'[class*="promptInput" i]': HIDE,
|
| 457 |
+
'[class*="prompt-input" i]': HIDE,
|
| 458 |
+
'[class*="chatInput" i]': HIDE,
|
| 459 |
+
'[class*="chat-input" i]': HIDE,
|
| 460 |
+
'[class*="conversationInput" i]': HIDE,
|
| 461 |
+
'[class*="conversation-input" i]': HIDE,
|
| 462 |
+
'[class*="conversationFooter" i]': HIDE,
|
| 463 |
+
'[class*="conversation-footer" i]': HIDE,
|
| 464 |
+
'[class*="bottomBar" i]': HIDE,
|
| 465 |
+
'[class*="searchInputContainer" i]': HIDE,
|
| 466 |
+
'[data-testid*="conversation-input" i]': HIDE,
|
| 467 |
+
'[data-testid*="spotter-input" i]': HIDE,
|
| 468 |
+
};
|
| 469 |
+
|
| 470 |
+
// ---------------------------------------------------------------------------
|
| 471 |
+
// Host-side filters on the Analytics liveboard.
|
| 472 |
+
// Hierarchy: OWNER_COLUMN → CADENCE_NAME_COLUMN. Date: DATE_COLUMN.
|
| 473 |
+
// Option lists are fetched from FILTER_SOURCE_ID via the searchdata REST API;
|
| 474 |
+
// selections are pushed to the liveboard as runtime filters. Column names must
|
| 475 |
+
// match the data source's column display names for the runtime filter to bind.
|
| 476 |
+
// Runtime filters: https://developers.thoughtspot.com/docs/runtime-filters
|
| 477 |
+
// ---------------------------------------------------------------------------
|
| 478 |
+
|
| 479 |
+
/**
|
| 480 |
+
* Model queried for the filter option lists (same model the liveboard uses).
|
| 481 |
+
* Hierarchy: SEGMENT_COLUMN → REP_COLUMN → CADENCE_NAME_COLUMN. Date: DATE_COLUMN.
|
| 482 |
+
*/
|
| 483 |
+
export const FILTER_SOURCE_ID = WORKSHEET_ID;
|
| 484 |
+
export const SEGMENT_COLUMN = 'Rep Segment';
|
| 485 |
+
export const REP_COLUMN = 'Rep Name';
|
| 486 |
+
export const CADENCE_NAME_COLUMN = 'Cadence Name';
|
| 487 |
+
export const DATE_COLUMN = 'Load Date';
|
| 488 |
+
|
| 489 |
+
/**
|
| 490 |
+
* Hide the native runtime-filter pills / filter bar ThoughtSpot renders on the
|
| 491 |
+
* liveboard header — we drive filters from the host UI instead. Injected into
|
| 492 |
+
* the liveboard iframe via rules_UNSTABLE. Class names are version-fragile;
|
| 493 |
+
* verify/adjust via right-click → Inspect if a release renames them.
|
| 494 |
+
* Docs: https://developers.thoughtspot.com/docs/css-rules
|
| 495 |
+
*/
|
| 496 |
+
const PILL_HIDE = { display: 'none !important' };
|
| 497 |
+
export const HIDE_FILTER_PILL_RULES: Record<string, Record<string, string>> = {
|
| 498 |
+
'[class*="filterChip" i]': PILL_HIDE,
|
| 499 |
+
'[class*="filter-chip" i]': PILL_HIDE,
|
| 500 |
+
'[class*="appliedFilter" i]': PILL_HIDE,
|
| 501 |
+
'[class*="applied-filter" i]': PILL_HIDE,
|
| 502 |
+
'[class*="pinboardFilter" i]': PILL_HIDE,
|
| 503 |
+
'[class*="pinboard-filter" i]': PILL_HIDE,
|
| 504 |
+
'[class*="filterBar" i]': PILL_HIDE,
|
| 505 |
+
'[class*="filter-bar" i]': PILL_HIDE,
|
| 506 |
+
'[data-testid*="filter-chip" i]': PILL_HIDE,
|
| 507 |
+
'[data-testid*="pinboard-filters" i]': PILL_HIDE,
|
| 508 |
+
};
|
| 509 |
+
|
demoprep_app/portal/template-tse/src/content.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// content.ts — the SINGLE source of truth for every human-readable string in
|
| 3 |
+
// the portal. A reskin edits this file (or the codemod `scripts/apply-spec.mjs`
|
| 4 |
+
// writes it from a spec.json); nothing else under src/ hard-codes brand or
|
| 5 |
+
// domain copy. IDs / GUIDs / host / columns / theme live in config.ts.
|
| 6 |
+
//
|
| 7 |
+
// Defaults below are the neutral "Northwind" freight-brokerage template.
|
| 8 |
+
// ---------------------------------------------------------------------------
|
| 9 |
+
|
| 10 |
+
export interface LoginStat {
|
| 11 |
+
value: string;
|
| 12 |
+
label: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const CONTENT = {
|
| 16 |
+
// ---- identity ----------------------------------------------------------
|
| 17 |
+
company: 'Northwind',
|
| 18 |
+
aiName: 'Northwind AI', // shown as the fancy-chat brand + "Ask <aiName>" tab
|
| 19 |
+
website: 'https://www.northwind.com',
|
| 20 |
+
|
| 21 |
+
// ---- login hero --------------------------------------------------------
|
| 22 |
+
// loginTitleHtml may contain a single <br /> for the line break.
|
| 23 |
+
loginTitleHtml: 'Where freight<br />meets finance.',
|
| 24 |
+
loginSubtitle:
|
| 25 |
+
'Your brokerage command center — carrier capacity, freight corridors, and loads-on-time, with AI-powered answers on live data. Built on Northwind, powered by ThoughtSpot.',
|
| 26 |
+
loginStats: [
|
| 27 |
+
{ value: '1.4M+', label: 'Loads Analyzed' },
|
| 28 |
+
{ value: '98%', label: 'On-Time Visibility' },
|
| 29 |
+
{ value: 'Real-time', label: 'Capacity Intel' },
|
| 30 |
+
] as LoginStat[],
|
| 31 |
+
loginCardSubtitle: 'Enter any username and password to continue',
|
| 32 |
+
// {host} is substituted with THOUGHTSPOT_HOST's bare hostname at render time.
|
| 33 |
+
loginDemoNoteHtml:
|
| 34 |
+
"<strong>Sign in</strong> with your ThoughtSpot username & password for <code>{host}</code>. This origin must be CORS-allowlisted on that cluster for the embeds to load.",
|
| 35 |
+
|
| 36 |
+
// ---- decorative platform nav (product-context words, non-interactive) ---
|
| 37 |
+
platformNav: ['Broker', 'Shipper', 'Factor'],
|
| 38 |
+
|
| 39 |
+
// ---- tab labels --------------------------------------------------------
|
| 40 |
+
tabs: {
|
| 41 |
+
myReports: 'My Reports',
|
| 42 |
+
analytics: 'Analytics',
|
| 43 |
+
inline: 'Carriers', // the inline-insights list tab
|
| 44 |
+
action: 'Capacity', // the custom-action tab
|
| 45 |
+
ask: 'Ask Northwind AI',
|
| 46 |
+
spotter: 'Spotter',
|
| 47 |
+
},
|
| 48 |
+
|
| 49 |
+
// ---- page subtitles / states ------------------------------------------
|
| 50 |
+
analyticsSubtitle: 'Your live freight & capacity performance dashboard',
|
| 51 |
+
spotterSubtitle: 'Ask questions of your Northwind data in natural language.',
|
| 52 |
+
inline: {
|
| 53 |
+
subtitleReady:
|
| 54 |
+
'Carrier activity for your organization — select a carrier to view its insights',
|
| 55 |
+
subtitleIdle: 'Your carriers and their activity records',
|
| 56 |
+
loading: 'Loading carriers…',
|
| 57 |
+
errorTitle: "Couldn't load carriers",
|
| 58 |
+
errorMsg: 'Unable to load carrier data.',
|
| 59 |
+
moreLabel: 'View insights',
|
| 60 |
+
hideLabel: 'Hide insights',
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
// ---- analytics host filters -------------------------------------------
|
| 64 |
+
filters: { primaryLabel: 'Region', secondaryLabel: 'Freight corridor' },
|
| 65 |
+
|
| 66 |
+
// ---- Ask-AI (fancy chat) ----------------------------------------------
|
| 67 |
+
sampleQuestions: [
|
| 68 |
+
'Loads delivered by corridor',
|
| 69 |
+
'Top carriers by on-time rate',
|
| 70 |
+
'Capacity by region',
|
| 71 |
+
'Loads per carrier over time',
|
| 72 |
+
],
|
| 73 |
+
askWelcome:
|
| 74 |
+
'Ask me anything about Northwind — freight, carriers, capacity, best practices — or ask about your own data and I will chart it live.',
|
| 75 |
+
askEmptySub:
|
| 76 |
+
'Ask any analytical question about your freight, carriers, capacity, and coverage. Answers come straight from your live data with an interactive chart you can drill into.',
|
| 77 |
+
|
| 78 |
+
// ---- custom-action tab + modal ----------------------------------------
|
| 79 |
+
action: {
|
| 80 |
+
label: 'Request Bid', // the context-menu action name
|
| 81 |
+
subtitlePrefix: 'Lane capacity & carrier opportunities. Right-click a row and choose',
|
| 82 |
+
subtitleSuffix: 'to launch a request pre-filled from that row.',
|
| 83 |
+
modalTitle: 'Request Bid',
|
| 84 |
+
modalLead: 'Pre-filled from the selected row. Review and submit through Northwind.',
|
| 85 |
+
submitLabel: 'Submit',
|
| 86 |
+
successTitle: 'Request submitted',
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
// ---- floating chatbot + fallback brain --------------------------------
|
| 90 |
+
chatbot: {
|
| 91 |
+
welcome:
|
| 92 |
+
"Hi! I'm Northwind AI. Ask me about freight, carriers and capacity, or ask a question about your data.",
|
| 93 |
+
overview:
|
| 94 |
+
'Northwind is an AI-powered logistics platform that helps brokerages manage carrier capacity, freight corridors, and on-time delivery, with answers on live data.',
|
| 95 |
+
greetingExample: 'loads delivered by corridor',
|
| 96 |
+
examples: ['loads delivered by corridor', 'top carriers by on-time rate'],
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
// ---- monetization ------------------------------------------------------
|
| 100 |
+
// The paywall fires once, on the Nth Ask-AI question.
|
| 101 |
+
trialQuestionTrigger: 3,
|
| 102 |
+
upgradeReason:
|
| 103 |
+
'Upgrade to Northwind Premium to unlock drill-down, Ask AI and downloads.',
|
| 104 |
+
|
| 105 |
+
// Extra domain terms that mark a chat message as a DATA question. Folded into
|
| 106 |
+
// the chatbot's analytics router alongside the generic "by X" / "top N" / metric
|
| 107 |
+
// patterns, so a client's own vocabulary routes to Spotter instead of the FAQ.
|
| 108 |
+
analyticsKeywords: ['loads', 'carriers', 'capacity', 'corridor', 'lane', 'on-time', 'delivered', 'freight'],
|
| 109 |
+
} as const;
|
| 110 |
+
|
| 111 |
+
export type Content = typeof CONTENT;
|