mikeboone commited on
Commit
8aebc9f
·
2 Parent(s): fc2b1f93b90e27

Merge remote-tracking branch 'hf-test/main' into claude/custom-mcp-spotter-demo-125da8

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +13 -0
  2. CLAUDE.md +65 -4
  3. Dockerfile +13 -1
  4. chat_interface.py +60 -10
  5. demoprep_app/dataset/engine.py +159 -45
  6. demoprep_app/dataset/formula.py +456 -0
  7. demoprep_app/dataset/quality.py +66 -0
  8. demoprep_app/dataset/validator.py +20 -4
  9. demoprep_app/integrations/snowflake/__init__.py +11 -2
  10. demoprep_app/integrations/snowflake/quality_gate.py +620 -64
  11. demoprep_app/liveboards/blueprint.py +41 -5
  12. demoprep_app/liveboards/golden_layout.py +783 -56
  13. demoprep_app/liveboards/muze_templates.py +335 -0
  14. demoprep_app/liveboards/tml_yaml.py +41 -0
  15. demoprep_app/pipeline/build_demo.py +66 -3
  16. demoprep_app/portal/README.md +55 -0
  17. demoprep_app/portal/assets/triumph-logo.svg +1 -0
  18. demoprep_app/portal/assets/vantage-logo.svg +1 -0
  19. demoprep_app/portal/builder.py +565 -0
  20. demoprep_app/portal/references/SKILL.md +186 -0
  21. demoprep_app/portal/references/build-playbook.md +154 -0
  22. demoprep_app/portal/references/intake.md +52 -0
  23. demoprep_app/portal/references/spec-schema.md +79 -0
  24. demoprep_app/portal/references/spec.example.json +312 -0
  25. demoprep_app/portal/scripts/apply-spec.mjs +294 -0
  26. demoprep_app/portal/specs/triumph.json +239 -0
  27. demoprep_app/portal/specs/vantage.json +238 -0
  28. demoprep_app/portal/template-tse/.gitignore +20 -0
  29. demoprep_app/portal/template-tse/README.md +44 -0
  30. demoprep_app/portal/template-tse/index.html +19 -0
  31. demoprep_app/portal/template-tse/package-lock.json +1934 -0
  32. demoprep_app/portal/template-tse/package.json +26 -0
  33. demoprep_app/portal/template-tse/public/northwind-icon.svg +4 -0
  34. demoprep_app/portal/template-tse/public/skyscanner-icon.svg +13 -0
  35. demoprep_app/portal/template-tse/src/App.tsx +60 -0
  36. demoprep_app/portal/template-tse/src/assets/northwind-logo.svg +5 -0
  37. demoprep_app/portal/template-tse/src/components/BidModal.tsx +133 -0
  38. demoprep_app/portal/template-tse/src/components/ChatBot.tsx +186 -0
  39. demoprep_app/portal/template-tse/src/components/ColumnFilter.tsx +149 -0
  40. demoprep_app/portal/template-tse/src/components/CreateDashboardModal.tsx +108 -0
  41. demoprep_app/portal/template-tse/src/components/DateRangeFilter.tsx +155 -0
  42. demoprep_app/portal/template-tse/src/components/NorthwindLogo.tsx +25 -0
  43. demoprep_app/portal/template-tse/src/components/PinModal.tsx +81 -0
  44. demoprep_app/portal/template-tse/src/components/RepFilter.tsx +139 -0
  45. demoprep_app/portal/template-tse/src/components/RepHierarchyFilter.tsx +268 -0
  46. demoprep_app/portal/template-tse/src/components/TopBar.tsx +148 -0
  47. demoprep_app/portal/template-tse/src/components/TrialModal.tsx +67 -0
  48. demoprep_app/portal/template-tse/src/components/WinBackModal.tsx +158 -0
  49. demoprep_app/portal/template-tse/src/config.ts +509 -0
  50. 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 `./demoprep/bin/activate`
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 ./demoprep/bin/activate && python chat_interface.py
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 ./demoprep/bin/activate && python`
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
- RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*
 
 
 
 
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 and fail loudly if any is
3963
- # entirely zero/null (recurring zero-measure defect — derived
3964
- # measures like TOTAL_*_USD loading as all zeros → blank tiles).
 
 
3965
  from demoprep_app.integrations.snowflake import (
3966
- ZeroMeasureError,
 
 
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, schema_name, progress_callback=log_progress
 
 
 
 
3976
  )
3977
- except ZeroMeasureError as e:
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 passed "
3997
- f"({dq_profile['measure_columns_checked']} measure columns "
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 passed",
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
- values[m.name] = _format_value(_eval_formula(m.formula, values), m.fmt, clamp=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- frac = min(max(rng.uniform(low, high) * imult, 0.0), 1.0)
213
- values[m.name] = _format_value(values.get(m.share_of, 0.0) * frac, m.fmt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(base * mult, m.fmt)
 
 
 
231
 
232
  for m in fact.measures:
233
- row[m.name] = values.get(m.name, 0.0)
 
 
 
 
 
 
 
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) -> Any:
 
302
  """Round a value for storage. clamp bounds sampled base measures to a sane
303
- range (no negative units, pct in 0-100); derived measures pass clamp=False
304
- so their stored value stays faithful to the formula the validator checks."""
 
 
 
 
 
 
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), 100.0)
313
- return round(value, 2)
314
  return round(value, 2)
315
 
316
 
317
- def _sql_type(measure: BlueprintMeasure) -> str:
318
- return "NUMBER" if measure.fmt == "int" else "NUMBER(16,2)"
319
-
320
-
321
- # --- Safe arithmetic formula evaluation (no eval) --------------------------
322
-
323
- _OPS = {
324
- ast.Add: operator.add,
325
- ast.Sub: operator.sub,
326
- ast.Mult: operator.mul,
327
- ast.Div: lambda a, b: a / b if b else 0.0,
328
- ast.USub: operator.neg,
329
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
 
332
- def _eval_formula(formula: str, values: dict[str, float]) -> float:
333
- try:
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
- def _eval_node(node: ast.AST, values: dict[str, float]) -> float:
341
- if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
342
- return _OPS[type(node.op)](_eval_node(node.left, values), _eval_node(node.right, values))
343
- if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
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
- try:
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
- from demoprep_app.dataset.engine import _eval_formula # single source of truth
 
 
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
- expected = _eval_formula(m.formula, {k: float(v) for k, v in row.items() if isinstance(v, (int, float))})
 
 
 
 
 
 
 
 
 
 
 
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
- if misses > len(sample) * 0.05:
171
- report.formula_errors.append(f"{m.name}: {misses}/{len(sample)} sampled rows deviate from formula '{m.formula}'.")
 
 
 
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__ = ["populate_dataset_bundle", "run_measure_quality_gate", "ZeroMeasureError"]
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 gate for generated demo schemas.
2
-
3
- Guards against the recurring zero-measure defect: fact tables where derived
4
- measure columns (e.g. TOTAL_LOAD_REVENUE_USD, BROKER_MARGIN_USD) load as 100%
5
- zero/null while their input columns are populated, which renders liveboard
6
- tiles blank. Runs immediately after data population, against what was actually
7
- loaded into Snowflake, so it is independent of how the data was generated.
8
-
9
- The gate profiles every MEASURE column (numeric columns that are not join
10
- keys, mirroring ThoughtSpotDeployer._determine_column_type) and raises
11
- ZeroMeasureError when any measure is entirely zero/null. No silent fallback:
12
- a failed gate fails the run with the offending columns named.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- class ZeroMeasureError(Exception):
32
- """One or more measure columns loaded entirely zero/null."""
33
 
34
- def __init__(self, failures: list[dict], profile: dict):
 
 
 
 
 
 
 
 
 
35
  self.failures = failures
36
  self.profile = profile
37
- details = "; ".join(
38
- f"{f['table']}.{f['column']} ({f['row_count']} rows, all zero/null)"
39
- for f in failures
40
- )
41
- super().__init__(
42
- f"Data quality gate failed — {len(failures)} measure column(s) are "
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; fail loudly on all-zero ones.
71
 
72
- Returns a profile dict:
 
 
 
 
 
 
 
 
73
  {
74
- "tables_checked": int,
75
- "measure_columns_checked": int,
76
- "failures": [], # empty on success (non-empty raises instead)
77
- "warnings": ["FACT_X.COL is 99.8% zero/null (1/500 non-zero)", ...],
78
- "columns": {"TABLE.COLUMN": {"row_count": n, "non_zero": m}, ...},
 
 
 
 
79
  }
80
 
81
- Raises ZeroMeasureError when any measure column in a populated table is
82
- entirely zero/null. Profiling errors (bad schema name, connection loss)
83
- propagate as-is — the gate never passes silently.
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
- select_parts = ["COUNT(*)"]
122
- for col in columns:
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 row_count == 0:
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
- if non_zero == 0:
147
- profile["failures"].append(
148
- {"table": table_name, "column": col, "row_count": row_count}
149
- )
150
- elif non_zero / row_count < MOSTLY_ZERO_WARN_THRESHOLD:
151
- pct_zero = 100.0 * (1 - non_zero / row_count)
152
- profile["warnings"].append(
153
- f"{table_name}.{col} is {pct_zero:.1f}% zero/null "
154
- f"({non_zero}/{row_count} non-zero)"
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["failures"]:
165
- raise ZeroMeasureError(profile["failures"], profile)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": build_demo_notes_spec(company_data, use_case, filters),
 
 
 
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
- tabs.append({"name": "Executive Overview", "tiles": []})
224
- target_tab = tabs[-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- kpi_count = 4 if target_viz_count >= 14 else 3
 
 
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
- groups = _build_groups(data_visualizations, plan)
 
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, kpi_changes, chart_changes, mode_changes, color_changes, title_changes):
 
 
188
  if change:
189
  changes.append(change)
190
  return updated, changes
191
 
192
 
193
- def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> List[Dict[str, Any]]:
 
 
 
 
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", "detail"):
 
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
- _promote_executive_analysis(buckets)
 
 
 
 
 
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
- EXEC_ANALYSIS_TARGET = 5 # trend + driver charts we want on the executive tab
245
- MANAGER_MIN = 1 # leave at least this many analysis charts for the manager tab
 
 
 
 
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
- available = len(buckets["breakdown"]) + len(buckets["ranking"])
257
- if available < 4:
258
- # Small board: leave breakdowns/rankings on the manager tab as-is.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  return
260
- promotable = max(0, available - MANAGER_MIN)
261
- if promotable <= 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  return
263
 
264
- needed = min(EXEC_ANALYSIS_TARGET - executive_analysis_count, promotable)
265
- while needed > 0 and buckets["breakdown"]:
266
- buckets["driver"].append(buckets["breakdown"].pop(0))
267
- needed -= 1
268
- while needed > 0 and buckets["ranking"]:
269
- buckets["driver"].append(buckets["ranking"].pop(0))
270
- needed -= 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- executive_groups = [group for group in groups if group.get("tab") == executive_tab]
282
- operations_groups = [group for group in groups if group.get("tab") == manager_tab]
 
 
 
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
- if operations_groups:
299
- tabs.append(
300
- {
301
- "name": manager_tab,
302
- "description": "Manager-level breakdowns, rankings, and detail",
303
- "tiles": _top_level_tiles(operations_groups),
304
- "group_layouts": [_group_layout(group) for group in operations_groups],
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
- KPI_ROW_H = 5
368
- CHART_ROW_H = 10
 
 
 
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": "true",
406
  "hide_group_tile_description": "false",
407
- "hide_tile_description": "false",
408
- "kpi_hero_font_size": "L",
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 > 2:
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
- chart.pop("client_state_v2", None)
 
 
 
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 line is really a KPI (also handled upstream).
 
 
 
 
 
 
 
1004
  if (
1005
- chart_type in {"LINE", "AREA", "ADVANCED_LINE", "ADVANCED_AREA"}
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 = engine.generate(blueprint, row_count_guidance)
 
 
137
  report = validate_bundle(blueprint, dataset)
138
  progress(report.summary())
139
  if report.passed:
140
  break
141
- if report.integrity_errors or report.formula_errors:
142
- # Engine bugs, not tuning issues — do not loop on these.
 
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 &amp; 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 &amp; 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 &amp; 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&rsquo;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 &amp; 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;