Add Excel export, rewrite Sheet pull to transposed layout, enrich battlecards

- SyncToSheet.vue: add "Download Excel" export alongside Copy rows,
  using exceljs (dynamically imported to avoid bundling ~940KB into
  AnalysePage) instead of xlsx (unpatched high-severity CVEs)
- appscript/Sync.gs: rewrite the Sheet-pull layout from column-per-field/
  row-per-competitor to the transposed layout (fields down column A,
  one column per competitor) — matches the dashboard's Copy rows/Excel
  export and the real legacy client reference sheet, which the old
  layout was never actually reconciled against. Row detection is now
  label-based and self-bootstraps a blank template on first pull.
- CLAUDE.md: correct §15 to document the transposed layout instead of
  the stale column-per-field description
- battlecard_service.py: replace two hardcoded placeholder strings
  (positioning_summary, recent_changes) with real computed data —
  actual price_history-derived recent changes, and richer product
  summaries (service levels, target audience, activities). Raise
  BATTLECARD_PRODUCT_LIMIT 10->50 and loosen prompt word caps so the
  richer input isn't compressed away
- BattlecardCard.vue: render pricing_observation, generated but never
  displayed before
- pb_migrations/009: fix battlecards.generated_at to stamp on update,
  not just create, so regenerated battlecards show an accurate
  "Updated" timestamp

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
JasonFraser 2026-07-05 23:08:06 -04:00
parent a25909a459
commit 965028e6c2
11 changed files with 1664 additions and 166 deletions

171
CLAUDE.md
View File

@ -603,7 +603,7 @@ bettersight/
├── appscript/ # Sheet-initiated pull integration — 3 files
│ ├── Code.gs # onOpen(), install entry point
│ ├── Validation.gs # validateLicence() — email only
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectColumns()
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
│ └── Sidebar.html # Tab selector + pull button + last-pull status
├── n8n/
│ └── workflows/ # n8n workflow JSON exports
@ -2371,23 +2371,50 @@ Firecrawl is in the MVP stack for `/monitor` only.
## 11. Battlecard Generation
After each scrape run completes, n8n calls `/internal/battlecard/<competitor_id>`.
After each scrape run completes (whether triggered by a manual dashboard
analysis or the nightly watchlist cron — see §7's "Trip watchlist"),
`jobs.py` calls `generate_battlecard()` directly whenever a competitor's
refresh path produced genuinely fresh data (real extraction, not a
cache reuse and not a hash-confirmed-unchanged result).
```python
def generate_battlecard(competitor_id: str, tenant_id: str) -> dict:
"""
Pulls latest product data for a competitor from PocketBase.
Passes structured data to Claude Haiku via OpenRouter.
Saves generated battlecard text and JSON back to PocketBase.
Returns battlecard dict for immediate use.
Pulls a competitor's tracked products (up to BATTLECARD_PRODUCT_LIMIT
= 50, newest first) from PocketBase, plus their real recent
price_history, and passes a real structured summary to the
OpenRouter extraction model. Saves generated battlecard text and
JSON back to PocketBase. Returns battlecard dict for immediate use.
Data flow:
competitor_id → PocketBase products (latest 10) →
structured prompt → OpenRouter (claude-haiku) →
competitor_id → PocketBase products (latest 50) →
_summarise_products() (top trips, trip count, price range, avg
duration, service levels, target audiences, activities) +
_summarise_recent_price_changes() (real price_history moves in
the last 30 days, sorted by magnitude) →
structured prompt → OpenRouter (via core.extractor) →
battlecard text + JSON → PocketBase battlecards table
"""
```
`BATTLECARD_PRODUCT_LIMIT` was previously 10 — skewed the summary
toward whichever products happened to be scraped most recently instead
of a reasonably complete view of the tracked catalogue. Raised to 50
(adjustable here only).
`{positioning_summary}` and `{recent_changes}` were previously hardcoded
placeholder strings (`'Derived from current product catalogue.'` and
`'See price_history for this competitor.'`) — the model was being told
those inputs existed when they never varied per competitor or per run.
`positioning_summary` is removed outright (it was also circular — the
prompt's own *output* already asks the model to derive `"positioning"`).
`recent_changes` is now a real sentence built from that competitor's
actual `price_history` rows within the last 30 days (every row already
represents a genuine price move — `_save_scrape_result()` only writes
one when the price actually changed — so no magnitude filter is needed,
just the recency window), falling back to "No significant price changes
in the last 30 days." when none qualify.
Prompt template:
```
You are a competitive intelligence analyst for an adventure travel operator.
@ -2395,16 +2422,22 @@ Based on this competitor data, write a concise battlecard.
Competitor: {name}
Top trips: {top_trips}
Number of tracked trips: {trip_count}
Price range: {price_low} - {price_high} USD
Avg duration: {avg_duration} days
Positioning: {positioning_summary}
Recent changes: {recent_changes}
Service levels offered: {service_levels}
Typical target audience: {target_audiences}
Common activities/themes: {activities_summary}
Recent price changes (last 30 days): {recent_changes}
Write:
1. One line positioning summary (max 15 words)
2. Three strengths (bullet points, max 8 words each)
3. Three weaknesses (bullet points, max 8 words each)
4. One pricing observation (max 20 words)
Write — ground every point in the specific data above (real prices,
service levels, activities, audience, recent price moves) rather than
generic statements that could apply to any competitor:
1. One line positioning summary (max 20 words)
2. Three strengths (bullet points, max 14 words each)
3. Three weaknesses (bullet points, max 14 words each)
4. One pricing observation (max 30 words) — reference the actual price
range and/or a real recent price change when one is available
Return ONLY valid JSON:
{
@ -2415,6 +2448,32 @@ Return ONLY valid JSON:
}
```
Word limits were previously 15/8/8/20 — tight enough that the richer
input data above (added in the same pass as removing the placeholder
strings) was mostly getting compressed away into generic-sounding
fragments rather than actually showing up in the output. Loosened once
the input data itself became real; still a scannable card, not a full
narrative (that's what the per-run `comments` field, §7, already is —
the two are intentionally different: `comments` is a one-off comparison
for a specific trip match-up, a battlecard is an evergreen standing
summary of a competitor overall).
`BattlecardCard.vue` previously rendered `positioning` and the
`strengths`/`weaknesses` pills only — `content_json.pricing_observation`
was generated and stored the whole time but never actually displayed
anywhere on the card. Now rendered as its own line below the pills.
`battlecards.generated_at` was `autodate('generated_at', true)`
onCreate only, never onUpdate (migration 001's `autodate()` helper
defaults `onUpdate` to `false`). Every regeneration since a
battlecard's first creation genuinely updated `content`/`content_json`
underneath while the dashboard's "Updated {generated_at}" timestamp
stayed frozen at the original creation date — a real battlecard
refresh could look untouched for days. Fixed via migration 009
(in-place field mutation — `collection.fields.getByName('generated_at').onUpdate = true`
— not a destructive remove+re-add, which would have wiped every
existing battlecard's real creation timestamp for no benefit).
---
## 12. Semantic Trip Matching
@ -2605,8 +2664,8 @@ their workbook exactly as before.
**The PM can rename the imported tab to anything.**
The sidebar's tab selector dropdown reads live tab names via
getWorkbookTabs() every time it opens, and detectColumns() maps
data to columns by header name — never by tab name or column position.
getWorkbookTabs() every time it opens, and detectFieldRows_() maps
data to rows by label — never by tab name or row position.
Rename the tab "Comp Intel", "Peru Research 2026", or leave it as
"Bettersight Analysis" — pullLatestResults() writes correctly either
way as long as the PM selects the intended tab from the dropdown.
@ -2637,25 +2696,47 @@ way as long as the PM selects the intended tab from the dropdown.
The template Sheet we host on Google Drive contains a single tab
named Bettersight Analysis by default (PM can rename after import).
**Column structure:** Row 1 is a bold header row with column names
matching STANDARD_FIELDS in the exact order the extractor produces.
Rows 2+ are empty and will be populated by pullLatestResults() based
on header-name column detection.
**Row structure — TRANSPOSED, not column-per-field.** Column A holds
one row per field label (company, link, trip name, pricing fields,
etc. — the exact list and order live in `appscript/Sync.gs`'s
`STANDARD_ROWS`/`NGS_ROWS`, hand-ported from the dashboard's own
`frontend/src/config/resultFields.js`). Row 1 is reserved for
competitor names, starting at column B — one column per competitor in
the most recent run. This matches the dashboard's Copy rows /
Download Excel export exactly, both of which were verified against
the real legacy client reference sheet ("G Compete App V1.xlsx"). An
earlier version of this section (and the `detectColumns()` design it
described) specified the opposite layout — column-per-field headers
in row 1, one row appended per competitor — which was never actually
reconciled against that real reference file; `Sync.gs` was rewritten
to match the transposed layout instead of the reverse.
The template tab does not need to be pre-populated with row labels at
all — a blank tab is enough. `detectFieldRows_()` bootstraps column A
from `STANDARD_ROWS`/`NGS_ROWS` (setting the 'Field' corner label in
A1) the first time it finds no recognisable labels there, so the very
first pull self-describes the sheet. Every subsequent pull REPLACES
the previously-written competitor columns (clears columns B onward)
rather than appending new columns indefinitely — the tab always
reflects the single most recent run, same as the dashboard's results
view.
**Header resilience:**
- If the PM renames a column header, detectColumns() still finds it
as long as the header name is one of the recognised aliases in
detectColumns()'s header alias map
- If the PM inserts new columns, reorders columns, or deletes optional
columns, detectColumns() adapts — never hardcoded column positions
- If detectColumns() cannot find a required header, it logs a warning
to the sidebar and falls back to appending the missing column at the
end of the existing data range with the standard name
- If the PM renames a row label, detectFieldRows_() still finds it as
long as it matches (case/punctuation-insensitive) the row's label —
substituting the PM's real company name into the comments row's
`{company}` token first, since that's what's actually written to
the cell
- If the PM reorders rows or deletes optional rows, detectFieldRows_()
adapts — never hardcoded row positions
- If detectFieldRows_() cannot find ANY recognisable row label at all
(a genuinely blank tab), it bootstraps the full row list from
scratch — see above
**Template hosting:**
The template is a single Google Sheets file on Drive at a stable share
URL. The URL is stored as an env var TEMPLATE_SHEET_URL and served via
GET /account/template. Any updates to the template columns are made
GET /account/template. Any updates to the template's row list are made
in-place on the source file — no versioning needed since the PM's
copy is a snapshot at their moment of import.
@ -2665,7 +2746,7 @@ copy is a snapshot at their moment of import.
appscript/
Code.gs — onOpen(), install entry point
Validation.gs — validateLicence() email-only
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
Sidebar.html — tab selector + pull button + last-pull status
```
@ -2677,17 +2758,21 @@ appscript/
* and writes it to the selected tab of the currently open workbook.
* Sheet-initiated only — cannot be triggered by any external server.
*
* Writes results using header-based column detection — never
* hardcoded column positions. Resilient to any sheet restructuring.
* Writes results in the TRANSPOSED layout (one row per field in
* column A, one column per competitor starting at column B) — matches
* the dashboard's Copy rows / Download Excel export exactly. Row
* detection is label-based, never hardcoded row positions —
* resilient to any sheet restructuring.
*
* Flow:
* PM clicks [Pull latest results] in sidebar →
* validateLicence() confirms authorisation →
* GET Flask /research/history/latest?email={sessionEmail} →
* stored results returned →
* detectColumns(sheet) builds { field: column } map →
* writeResults(results, columnMap, sheet) writes into selected tab →
* sidebar shows "Pulled: Today 9:14am · 5 competitors · Peru"
* stored results + tab_type returned →
* detectFieldRows_(sheet, rows) builds { field: rowNumber } map →
* writeResultsToSheet_(results, rowMap, rows, sheet) writes into
* selected tab, one column per competitor →
* sidebar shows "Pulled: Today 9:14am · 5 competitors"
*/
function pullLatestResults(tabName) { ... }
@ -2702,11 +2787,13 @@ function getWorkbookTabs() {
}
/**
* Detects column positions by matching header names in row 1.
* Returns { fieldName: columnIndex } map.
* Falls back to STANDARD_FIELDS positions if headers not found.
* Detects each field's row number by matching labels in column A
* (rows 2+, row 1 reserved for competitor names). Returns
* { fieldKey: rowNumber }. Bootstraps column A from the row list
* (and sets the 'Field' corner label in A1) if no labels are found at
* all — a blank tab is a valid starting point.
*/
function detectColumns(sheet) { ... }
function detectFieldRows_(sheet, rows, companyName) { ... }
```
### Sidebar UI
@ -3598,7 +3685,7 @@ Phase 3 — Dashboard (PRIMARY — build before Apps Script)
Phase 4 — Apps Script (Sheet-initiated pull — simple)
31. Deploy as standalone Apps Script — not container-bound to any workbook
32. Validation.gs — validateLicence() email-only
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
34. Sidebar.html — tab selector + [Pull latest results] + last-pull status
35. Sheet template tab — importable into any existing workbook
36. Flask GET /research/history/latest route (reuses history handler)

View File

@ -5,56 +5,100 @@
* button), never from the dashboard or Flask, since a stateless
* backend has no way to reach into whichever Sheet a PM has open
* (CLAUDE.md §15).
*
* Layout: TRANSPOSED — one row per field (column A), one column per
* competitor (starting at column B), row 1 reserved for competitor
* names. This matches the dashboard's Copy rows / Download Excel
* export exactly (frontend/src/config/resultFields.js), which was
* itself verified against the real legacy client reference sheet
* ("G Compete App V1.xlsx") — NOT the column-per-field/row-per-
* competitor layout CLAUDE.md §15 originally described, which was
* never reconciled against that real reference file. ROW_DEFS below
* is a hand-ported mirror of resultFields.js's STANDARD_ROWS/NGS_ROWS
* — Apps Script runs in a separate sandbox with no module imports, so
* this list is duplicated rather than shared. Keep both in sync if
* either changes.
*/
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
// Every STANDARD_FIELDS column's recognised header aliases, written
// out explicitly rather than guessed at mid-scrape. The first alias
// in each list is the canonical label used when writing a brand-new
// header row. Matching is case- and punctuation-insensitive (see
// normaliseHeader_), so "Rack Rate", "rack rate", and "RACK-RATE" all
// match the same entry.
const FIELD_ALIASES = {
company: ['Company', 'Competitor', 'Competitor Name', 'Operator'],
link: ['Link', 'URL', 'Page URL', 'Trip URL', 'Web Link'],
tripName: ['Trip Name', 'Trip', 'Product Name', 'Tour Name'],
tripCode: ['Trip Code', 'Code', 'Product Code', 'SKU'],
serviceLevel: ['Service Level', 'Style', 'Accommodation Level', 'Class'],
groupSize: ['Group Size', 'Max Group Size', 'Group', 'Party Size'],
duration: ['Duration', 'Days', 'Length', 'Trip Length', 'Number of Days'],
meals: ['Meals', 'Included Meals', 'Total Meals', 'Meal Count'],
startLocation: ['Start Location', 'Departure City', 'Starts', 'Start City', 'Start Point'],
endLocation: ['End Location', 'End City', 'Ends', 'Finish City', 'End Point'],
departures: ['Departures', 'Departures Per Year', 'Frequency', 'Number of Departures'],
seasonality: ['Seasonality', 'Season', 'Price Bands', 'Seasonal Pricing'],
startDays: ['Start Days', 'Departure Days', 'Days of Week'],
targetAudience: ['Target Audience', 'Audience', 'Traveller Type', 'Traveler Type'],
hotels: ['Hotels', 'Accommodation', 'Hotel Type', 'Lodging'],
activities: ['Activities', 'Included Activities', 'Key Activities', 'Highlights'],
relevancy: ['Relevancy', 'Relevance', 'Match Score', 'Relevance Score'],
majorityPrice: ['Majority Price', 'Standard Price', 'Rack Rate', 'Price', 'Price USD', 'Adult Price'],
priceLow: ['Price Low', 'Low Price', 'Min Price', 'Lowest Price', 'Price From'],
priceHigh: ['Price High', 'High Price', 'Max Price', 'Highest Price', 'Price To'],
comments: ['Comments', 'Analysis', 'Notes', 'Observations'],
dateUsed: ['Date Used', 'Departure Date', 'Date', 'Sample Date'],
audPrice: ['AUD Price', 'AUD', 'Price AUD', 'Australian Dollar Price'],
cadPrice: ['CAD Price', 'CAD', 'Price CAD', 'Canadian Dollar Price'],
eurPrice: ['EUR Price', 'EUR', 'Price EUR', 'Euro Price'],
gbpPrice: ['GBP Price', 'GBP', 'Price GBP', 'British Pound Price', 'Pound Price']
};
function divOrNull_(a, b) {
if (a == null || b == null || b === 0) return null;
return a / b;
}
// Fallback column positions, used only when row 1 has no recognisable
// headers at all (e.g. a brand-new blank tab). Ordered left to right
// starting at column 1 — logs a warning when this path is taken.
const STANDARD_FIELDS_ORDER = Object.keys(FIELD_ALIASES);
function mulOrNull_(a, b) {
if (a == null || b == null) return null;
return a * b;
}
const STANDARD_ROWS = [
{ key: 'company', label: 'Company', source: 'name' },
{ key: 'link', label: 'Link' },
{ key: 'tripName', label: 'Trip Name' },
{ key: 'tripCode', label: 'Trip Code' },
{ key: 'serviceLevel', label: 'Service Level' },
{ key: 'groupSize', label: 'Group size' },
{ key: 'duration', label: 'Duration (days)' },
{ key: 'meals', label: 'Meals' },
{ key: 'startLocation', label: 'Start Location' },
{ key: 'endLocation', label: 'End Location' },
{ key: 'departures', label: '# Departures Offered per year' },
{ key: 'totalInventory', label: 'Total inventory', compute: function (r) { return mulOrNull_(r && r.departures, r && r.groupSize); } },
{ key: 'seasonality', label: 'Seasonality (what are the price bands in terms of dates?)' },
{ key: 'startDays', label: 'Start days of week' },
{ key: 'targetAudience', label: 'Target audience if known' },
{ key: 'hotels', label: 'Hotels (if applicable)' },
{ key: 'activities', label: 'Included Activities' },
{ key: 'relevancy', label: 'Relevancy rating' },
{ key: 'majorityPrice', label: 'Majority Price (USD)' },
{ key: 'priceLow', label: 'Price low (USD)' },
{ key: 'priceHigh', label: 'Price high (USD)' },
{ key: 'pricePerDay', label: 'Price per day (USD)', compute: function (r) { return divOrNull_(r && r.majorityPrice, r && r.duration); } },
{ key: 'comments', label: 'Suggested changes for {company}/ Competitive Comments' },
{ key: 'dateUsed', label: 'Date Used for Majority Price' },
{ key: 'audPrice', label: 'AUD Majority price' },
{ key: 'cadPrice', label: 'CAD Majority price' },
{ key: 'eurPrice', label: 'EUR Majority price' },
{ key: 'gbpPrice', label: 'GBP Majority price' },
{ key: 'audPricePerDay', label: 'AUD Price/day', compute: function (r) { return divOrNull_(r && r.audPrice, r && r.duration); } },
{ key: 'cadPricePerDay', label: 'CAD Price/day', compute: function (r) { return divOrNull_(r && r.cadPrice, r && r.duration); } },
{ key: 'eurPricePerDay', label: 'EUR Price/day', compute: function (r) { return divOrNull_(r && r.eurPrice, r && r.duration); } },
{ key: 'gbpPricePerDay', label: 'GBP Price/day', compute: function (r) { return divOrNull_(r && r.gbpPrice, r && r.duration); } }
];
// Spliced in after 'activities' — matches resultFields.js's insertAfter().
const NGS_EXTRA_ROWS = [
{ key: 'exclusiveAccess', label: 'Exclusive Access/Special Unique Inclusions' },
{ key: 'groupLeader', label: 'Group Leader and/or Local Expert Description' },
{ key: 'sustainability', label: 'Sustainability Inclusions (if applicable)' }
];
function getRowsForTabType_(tabType) {
if (tabType !== 'NGS') return STANDARD_ROWS;
const activitiesIndex = STANDARD_ROWS.findIndex(function (r) { return r.key === 'activities'; });
return STANDARD_ROWS.slice(0, activitiesIndex + 1)
.concat(NGS_EXTRA_ROWS, STANDARD_ROWS.slice(activitiesIndex + 1));
}
function normaliseHeader_(text) {
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
function canonicalLabel_(field) {
return FIELD_ALIASES[field][0];
/**
* Substitutes the '{company}' token in the comments row's label.
* Apps Script has no direct route to the tenant's display name (only
* the dashboard's Pinia store carries that) — always falls back to
* 'your company'. A no-op for every other row (no token present).
*/
function formatLabel_(label, companyName) {
return label.replace('{company}', companyName || 'your company');
}
function rawValue_(row, item) {
if (row.compute) return row.compute(item.result || {});
if (row.source === 'name') return item.name;
return item.result ? item.result[row.key] : undefined;
}
/**
@ -69,84 +113,85 @@ function getWorkbookTabs() {
}
/**
* Detects the column index for each field by matching header names in
* row 1 of the target sheet against FIELD_ALIASES — never hardcoded
* column positions. Falls back to STANDARD_FIELDS_ORDER's left-to-right
* positions only when row 1 has no recognisable headers at all — logs
* a warning when that fallback is used.
* Detects the row index for each field by matching normalised labels
* in column A (rows 2+, row 1 is reserved for competitor names)
* against `rows` — never hardcoded row positions, so a PM can reorder
* or delete rows freely. Falls back to `rows`' own order, starting at
* row 2, only when column A has no recognisable labels at all (e.g. a
* brand-new blank tab) — writes those labels in on the spot (and sets
* the 'Field' corner label in A1) so the sheet becomes self-describing
* from that point on. A PM never has to hand-type the ~30 row labels
* themselves — leaving the tab blank and pulling once is enough.
*
* Data flow:
* sheet → read row 1 → normalise each header cell →
* for each field, check every alias in FIELD_ALIASES against every
* header cell → first match wins → column index →
* { fieldName: columnIndex } map returned
* sheet + rows + companyName → read column A (rows 2..lastRow) →
* normalise each cell → for each row def, match against its
* (company-substituted) label → { fieldKey: rowNumber } map →
* if nothing matched at all: bootstrap column A from `rows` in
* order → same map, now fully populated
*/
function detectColumns(sheet) {
const lastColumn = Math.max(sheet.getLastColumn(), 1);
const headerRow = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
const normalisedHeaders = headerRow.map(normaliseHeader_);
function detectFieldRows_(sheet, rows, companyName) {
const lastRow = sheet.getLastRow();
const rowMap = {};
const columnMap = {};
Object.keys(FIELD_ALIASES).forEach(function (field) {
const normalisedAliases = FIELD_ALIASES[field].map(normaliseHeader_);
for (let col = 0; col < normalisedHeaders.length; col++) {
if (normalisedHeaders[col] && normalisedAliases.indexOf(normalisedHeaders[col]) !== -1) {
columnMap[field] = col + 1; // 1-indexed columns
break;
if (lastRow >= 2) {
const columnA = sheet.getRange(2, 1, lastRow - 1, 1).getValues().map(function (r) { return r[0]; });
const normalisedColumn = columnA.map(normaliseHeader_);
rows.forEach(function (row) {
const target = normaliseHeader_(formatLabel_(row.label, companyName));
for (let i = 0; i < normalisedColumn.length; i++) {
if (normalisedColumn[i] && normalisedColumn[i] === target) {
rowMap[row.key] = i + 2; // +2: back to 1-indexed, offset past row 1
break;
}
}
}
});
const foundAnyHeader = Object.keys(columnMap).length > 0;
if (!foundAnyHeader) {
Logger.log('detectColumns: no recognisable headers in row 1 — falling back to STANDARD_FIELDS_ORDER positions');
STANDARD_FIELDS_ORDER.forEach(function (field, i) {
columnMap[field] = i + 1;
});
}
return columnMap;
const foundAny = Object.keys(rowMap).length > 0;
if (!foundAny) {
Logger.log('detectFieldRows_: no recognisable row labels in column A — bootstrapping a fresh template');
sheet.getRange(1, 1).setValue('Field');
rows.forEach(function (row, i) {
const rowNumber = i + 2;
sheet.getRange(rowNumber, 1).setValue(formatLabel_(row.label, companyName));
rowMap[row.key] = rowNumber;
});
}
return rowMap;
}
/**
* Writes the header row (if the sheet is empty) and appends one row
* per competitor result, using columnMap from detectColumns(). Only
* writes fields present in columnMap.
* Writes one column per competitor into the sheet, using rowMap from
* detectFieldRows_(). Only writes fields present in rowMap — a row the
* PM deleted from the template is silently skipped, same resilience
* rule the old column-based model used.
*
* Every pull REPLACES the previously-written competitor columns
* (clears columns B onward first) rather than appending indefinitely
* — this tab always reflects the single most recent run, same as the
* dashboard's results view. Column A's row labels are left untouched.
*/
function writeResultsToSheet_(results, columnMap, sheet) {
if (sheet.getLastRow() === 0) {
const headerCells = [];
Object.keys(columnMap).forEach(function (field) {
headerCells[columnMap[field] - 1] = canonicalLabel_(field);
});
sheet.getRange(1, 1, 1, headerCells.length).setValues([headerCells.map(function (h) { return h || ''; })]);
}
let nextRow = sheet.getLastRow() + 1;
function writeResultsToSheet_(results, rowMap, rows, sheet) {
const successItems = (results && results.success) || [];
successItems.forEach(function (item) {
const extracted = item.result || {};
const rowValues = {};
rowValues.company = item.name;
rowValues.link = extracted.link || (item.product && item.product.url) || '';
const lastColumn = sheet.getLastColumn();
if (lastColumn > 1) {
sheet.getRange(1, 2, sheet.getMaxRows(), lastColumn - 1).clearContent();
}
Object.keys(FIELD_ALIASES).forEach(function (field) {
if (field === 'company' || field === 'link') return;
const value = extracted[field];
if (value !== undefined && value !== null) {
rowValues[field] = value;
}
successItems.forEach(function (item, index) {
const col = index + 2; // column A is labels, competitors start at column B
sheet.getRange(1, col).setValue(item.name);
rows.forEach(function (row) {
const rowNumber = rowMap[row.key];
if (!rowNumber) return;
const value = rawValue_(row, item);
sheet.getRange(rowNumber, col).setValue(value == null ? '' : value);
});
Object.keys(rowValues).forEach(function (field) {
const col = columnMap[field];
if (col) {
sheet.getRange(nextRow, col).setValue(rowValues[field]);
}
});
nextRow += 1;
});
return successItems.length;
@ -160,8 +205,9 @@ function writeResultsToSheet_(results, columnMap, sheet) {
* Data flow:
* validateLicence() → confirms authorisation →
* GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... →
* stored results returned → target tab resolved (creates it if
* missing) → detectColumns(sheet) → writeResultsToSheet_() →
* stored results + tab_type returned → target tab resolved (creates
* it if missing) → getRowsForTabType_(tab_type) selects the
* Standard/NGS row list → detectFieldRows_() → writeResultsToSheet_() →
* last-pull status saved to UserProperties, sync_from_sheet
* onboarding flag flipped on Flask on first success →
* { success, rowsWritten, summary } returned to the sidebar
@ -202,8 +248,9 @@ function pullLatestResults(tabName) {
sheet = spreadsheet.insertSheet(targetTabName);
}
const columnMap = detectColumns(sheet);
const rowsWritten = writeResultsToSheet_(run.results, columnMap, sheet);
const rows = getRowsForTabType_(run.tab_type);
const rowMap = detectFieldRows_(sheet, rows, null);
const rowsWritten = writeResultsToSheet_(run.results, rowMap, rows, sheet);
const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`;
const timestamp = new Date().toLocaleString();

View File

@ -6,7 +6,13 @@
* frontend bundle. Real per-PM authorization is the email check inside
* validateLicence() below, not this header.
*
* Replace these two values before publishing the add-on.
* TEMPORARY — pointed at a local dev cloudflared quick tunnel for
* testing the pull flow end-to-end before a real production domain
* exists. Quick tunnels are not stable: the hostname changes every
* time cloudflared restarts, and Cloudflare gives no uptime guarantee
* on the free, account-less tier. Replace both values with the real
* production API base URL + API_SECRET_KEY before publishing the
* add-on for real PM use.
*/
const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io';
const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY';

View File

@ -0,0 +1,33 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Fixes battlecards.generated_at to also stamp on UPDATE, not just
// CREATE. 001_initial_schema.js's autodate('generated_at', true) set
// onCreate=true, onUpdate=false (the helper's default) — meaning every
// subsequent battlecard_service.generate_battlecard() regeneration (an
// upsert -> PATCH on the existing record, per
// battlecard_repository.upsert()) left generated_at frozen at the
// battlecard's FIRST-ever creation date, even though content/
// content_json genuinely change underneath. BattlecardCard.vue
// prominently shows "Updated {generated_at}", so a regenerated
// battlecard with real fresh content looked like it hadn't been
// touched in days — confirmed directly: a manual generate_battlecard()
// call updated content/content_json but generated_at stayed at its
// original timestamp from days earlier.
//
// In-place field mutation (getByName + reassign) rather than
// remove+re-add — removing an existing field drops its underlying
// column (destructive), which would wipe every existing battlecard's
// real creation timestamp for no benefit; this only changes the
// field's future stamping behavior.
migrate((app) => {
const collection = app.findCollectionByNameOrId('battlecards');
const field = collection.fields.getByName('generated_at');
field.onUpdate = true;
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId('battlecards');
const field = collection.fields.getByName('generated_at');
field.onUpdate = false;
return app.save(collection);
});

View File

@ -1,25 +1,45 @@
import logging
from datetime import datetime, timedelta, timezone
from repositories.product_repository import product_repository
from repositories.competitor_repository import competitor_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.price_history_repository import price_history_repository
logger = logging.getLogger(__name__)
# How many of a competitor's most-recently-scraped active products feed
# a battlecard. Previously 10 — skewed the summary toward whatever
# happened to be scraped most recently (e.g. this week's watchlist
# picks) instead of a reasonably complete view of the tracked catalogue.
# Adjustable here only, same convention as core/scraper.py's
# CACHE_STALENESS_HOURS.
BATTLECARD_PRODUCT_LIMIT = 50
# How far back price_history is considered "recent" for the battlecard's
# recent-changes line.
RECENT_CHANGES_WINDOW_DAYS = 30
BATTLECARD_PROMPT = '''You are a competitive intelligence analyst for an adventure travel operator.
Based on this competitor data, write a concise battlecard.
Competitor: {name}
Top trips: {top_trips}
Number of tracked trips: {trip_count}
Price range: {price_low} - {price_high} USD
Avg duration: {avg_duration} days
Positioning: {positioning_summary}
Recent changes: {recent_changes}
Service levels offered: {service_levels}
Typical target audience: {target_audiences}
Common activities/themes: {activities_summary}
Recent price changes (last 30 days): {recent_changes}
Write:
1. One line positioning summary (max 15 words)
2. Three strengths (bullet points, max 8 words each)
3. Three weaknesses (bullet points, max 8 words each)
4. One pricing observation (max 20 words)
Write ground every point in the specific data above (real prices,
service levels, activities, audience, recent price moves) rather than
generic statements that could apply to any competitor:
1. One line positioning summary (max 20 words)
2. Three strengths (bullet points, max 14 words each)
3. Three weaknesses (bullet points, max 14 words each)
4. One pricing observation (max 30 words) reference the actual price
range and/or a real recent price change when one is available
Return ONLY valid JSON:
{{
@ -30,28 +50,106 @@ Return ONLY valid JSON:
}}'''
def _top_values(values, limit=3):
"""
Dedupes a list of possibly-null strings, preserving first-seen
order, and joins the top `limit` into one comma-separated string.
Used to summarise service_level/target_audience/activities across a
competitor's tracked products without just picking the single most
recent one.
"""
seen = []
for value in values:
if value and value not in seen:
seen.append(value)
return ', '.join(seen[:limit])
def _summarise_products(products):
"""Reduces a list of product records into the plain values BATTLECARD_PROMPT needs."""
top_trips = ', '.join(p.get('trip_name', '') for p in products[:5] if p.get('trip_name'))
"""
Reduces a competitor's tracked products into the plain values
BATTLECARD_PROMPT needs. Previously only derived 3 numbers
(price range, avg duration) plus a joined trip-name list ignored
service_level/target_audience/activities even though every products
row already has them (CLAUDE.md §12).
"""
top_trips = ', '.join(p.get('trip_name', '') for p in products[:8] if p.get('trip_name'))
prices = [p['majority_price_usd'] for p in products if p.get('majority_price_usd')]
durations = [p['duration_days'] for p in products if p.get('duration_days')]
return {
'top_trips': top_trips or 'No products on record',
'trip_count': len(products),
'price_low': min(prices) if prices else 'unknown',
'price_high': max(prices) if prices else 'unknown',
'avg_duration': round(sum(durations) / len(durations)) if durations else 'unknown',
'service_levels': _top_values([p.get('service_level') for p in products]) or 'unknown',
'target_audiences': _top_values([p.get('target_audience') for p in products]) or 'unknown',
'activities_summary': _top_values([p.get('activities') for p in products], limit=5) or 'unknown',
}
def _summarise_recent_price_changes(products, days=RECENT_CHANGES_WINDOW_DAYS):
"""
Replaces the old hardcoded 'See price_history for this competitor.'
placeholder with a real summary of that competitor's actual recent
price moves the model was previously being told to reference data
it was never actually given.
Every price_history row already represents a genuine change
(analysis_service._save_scrape_result() only writes one when
old_price != new_price), so no extra magnitude filter is needed here
just a recency window. price_history has no competitor_id column
(only product_id/tenant_id per CLAUDE.md §12), so this reuses the
existing price_history_repository.list_for_product() per already-
fetched product rather than adding a new repository method.
Data flow:
products per product: price_history_repository.list_for_product(limit=3)
entries within `days` with a real change_percent
sorted by |change_percent| descending (most notable first)
top 5 joined into one line, or a "no changes" fallback if none qualify
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
changes = []
for product in products:
entries = price_history_repository.list_for_product(product['id'], limit=3)
for entry in entries:
scraped_at = entry.get('scraped_at')
change_percent = entry.get('change_percent')
if not scraped_at or change_percent is None:
continue
try:
scraped_dt = datetime.fromisoformat(scraped_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
continue
if scraped_dt < cutoff:
continue
direction = 'dropped' if change_percent < 0 else 'rose'
price = entry.get('majority_price_usd')
price_part = f' to ${price}' if price is not None else ''
changes.append((
abs(change_percent),
f"{product.get('trip_name') or 'a trip'} {direction} {abs(change_percent)}%{price_part}",
))
if not changes:
return f'No significant price changes in the last {days} days.'
changes.sort(key=lambda c: c[0], reverse=True)
return '; '.join(text for _, text in changes[:5])
def generate_battlecard(competitor_id, tenant_id):
"""
Pulls latest product data for a competitor from PocketBase, passes
structured data to the OpenRouter extraction model, and saves the
generated battlecard text/JSON back to PocketBase.
Pulls a competitor's tracked products (and their real recent price
history) from PocketBase, passes a real structured summary to the
OpenRouter extraction model, and saves the generated battlecard
text/JSON back to PocketBase.
Data flow:
competitor_id PocketBase products (latest 10)
structured prompt OpenRouter (claude-haiku via core.extractor)
competitor_id PocketBase products (latest BATTLECARD_PRODUCT_LIMIT)
_summarise_products() + _summarise_recent_price_changes()
structured prompt OpenRouter (via core.extractor)
battlecard text + JSON PocketBase battlecards table
battlecard dict returned for immediate use
"""
@ -61,17 +159,21 @@ def generate_battlecard(competitor_id, tenant_id):
from core.extractor import extract_with_openrouter
competitor = competitor_repository.get_by_id(competitor_id)
products = product_repository.get_latest_for_competitor(competitor_id, limit=10)
products = product_repository.get_latest_for_competitor(competitor_id, limit=BATTLECARD_PRODUCT_LIMIT)
summary = _summarise_products(products)
recent_changes = _summarise_recent_price_changes(products)
prompt = BATTLECARD_PROMPT.format(
name=competitor.get('name', 'Unknown') if competitor else 'Unknown',
top_trips=summary['top_trips'],
trip_count=summary['trip_count'],
price_low=summary['price_low'],
price_high=summary['price_high'],
avg_duration=summary['avg_duration'],
positioning_summary='Derived from current product catalogue.',
recent_changes='See price_history for this competitor.',
service_levels=summary['service_levels'],
target_audiences=summary['target_audiences'],
activities_summary=summary['activities_summary'],
recent_changes=recent_changes,
)
try:

View File

@ -1,9 +1,11 @@
import importlib
from datetime import datetime, timedelta, timezone
from services import battlecard_service
from repositories.tenant_repository import tenant_repository
from repositories.competitor_repository import competitor_repository
from repositories.product_repository import product_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.price_history_repository import price_history_repository
def _install_fake_extractor(monkeypatch, fn):
@ -74,3 +76,132 @@ def test_generate_battlecard_handles_no_products_gracefully(monkeypatch):
card = battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
assert card is not None
# ── _top_values ─────────────────────────────────────────────────────
def test_top_values_dedupes_and_preserves_first_seen_order():
result = battlecard_service._top_values(['Mid-range', 'Budget', 'Mid-range', 'Premium', 'Budget'])
assert result == 'Mid-range, Budget, Premium'
def test_top_values_skips_null_and_empty_entries():
result = battlecard_service._top_values([None, 'Families', '', None, 'Couples'])
assert result == 'Families, Couples'
def test_top_values_respects_limit():
result = battlecard_service._top_values(['A', 'B', 'C', 'D'], limit=2)
assert result == 'A, B'
def test_top_values_empty_input_returns_empty_string():
assert battlecard_service._top_values([]) == ''
# ── _summarise_products (richer fields) ────────────────────────────
def test_summarise_products_includes_trip_count_and_new_fields():
products = [
{'trip_name': 'Peru Classic', 'majority_price_usd': 2190, 'duration_days': 15,
'service_level': 'Mid-range', 'target_audience': 'Couples', 'activities': 'Trekking'},
{'trip_name': 'Peru Premium', 'majority_price_usd': 3200, 'duration_days': 15,
'service_level': 'Premium', 'target_audience': 'Couples', 'activities': 'Trekking, Rafting'},
]
summary = battlecard_service._summarise_products(products)
assert summary['trip_count'] == 2
assert summary['service_levels'] == 'Mid-range, Premium'
assert summary['target_audiences'] == 'Couples'
assert 'Trekking' in summary['activities_summary']
def test_summarise_products_new_fields_default_to_unknown_when_missing():
products = [{'trip_name': 'Peru Classic'}]
summary = battlecard_service._summarise_products(products)
assert summary['service_levels'] == 'unknown'
assert summary['target_audiences'] == 'unknown'
assert summary['activities_summary'] == 'unknown'
# ── _summarise_recent_price_changes ────────────────────────────────
def _iso_days_ago(days):
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
def test_summarise_recent_price_changes_includes_change_within_window():
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
price_history_repository.create({
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
'change_percent': -12.0, 'scraped_at': _iso_days_ago(5),
})
result = battlecard_service._summarise_recent_price_changes([product])
assert 'Peru Classic dropped 12.0%' in result
assert '$1990' in result
def test_summarise_recent_price_changes_ignores_entries_outside_window():
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
price_history_repository.create({
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
'change_percent': -12.0, 'scraped_at': _iso_days_ago(45),
})
result = battlecard_service._summarise_recent_price_changes([product])
assert result == 'No significant price changes in the last 30 days.'
def test_summarise_recent_price_changes_sorts_by_magnitude_descending():
product_a = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Small Move'})
product_b = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Big Move'})
price_history_repository.create({
'tenant_id': 't1', 'product_id': product_a['id'], 'majority_price_usd': 1000,
'change_percent': 2.0, 'scraped_at': _iso_days_ago(1),
})
price_history_repository.create({
'tenant_id': 't1', 'product_id': product_b['id'], 'majority_price_usd': 2000,
'change_percent': -25.0, 'scraped_at': _iso_days_ago(2),
})
result = battlecard_service._summarise_recent_price_changes([product_a, product_b])
assert result.index('Big Move') < result.index('Small Move')
def test_summarise_recent_price_changes_falls_back_when_no_price_history_at_all():
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
result = battlecard_service._summarise_recent_price_changes([product])
assert result == 'No significant price changes in the last 30 days.'
# ── generate_battlecard() end-to-end prompt content ────────────────
def test_generate_battlecard_prompt_uses_real_data_not_placeholders(monkeypatch):
tenant, competitor = _setup()
product = product_repository.get_latest_for_competitor(competitor['id'])[0]
product_repository.update(product['id'], {'service_level': 'Premium', 'target_audience': 'Couples'})
price_history_repository.create({
'tenant_id': tenant['id'], 'product_id': product['id'], 'majority_price_usd': 1990,
'change_percent': -9.1, 'scraped_at': _iso_days_ago(2),
})
captured = {}
_install_fake_extractor(monkeypatch, lambda prompt, content: captured.update(prompt=prompt) or {
'positioning': 'x', 'strengths': [], 'weaknesses': [], 'pricing_observation': 'x',
})
battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
assert 'Premium' in captured['prompt']
assert 'Couples' in captured['prompt']
assert 'dropped 9.1%' in captured['prompt']
assert 'See price_history for this competitor.' not in captured['prompt']
assert 'Derived from current product catalogue.' not in captured['prompt']
assert 'Positioning:' not in captured['prompt'] # positioning_summary input field removed entirely

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"@sentry/vue": "^7.120.3",
"axios": "^1.7.9",
"date-fns": "^3.6.0",
"exceljs": "^4.4.0",
"papaparse": "^5.4.1",
"pinia": "^2.2.6",
"pocketbase": "^0.25.1",

View File

@ -41,6 +41,53 @@ async function copyRows() {
// real pullLatestResults() success from inside the Sheet itself.
setTimeout(() => { copied.value = false }, 2000)
}
const downloading = ref(false)
// Same transposed shape and same raw (unformatted) values as copyRows()
// one row per field, one column per competitor so the two export
// paths never diverge. ExcelJS (not the more common SheetJS/`xlsx`
// package) specifically to avoid a known, currently-unpatched high-
// severity prototype-pollution/ReDoS advisory in `xlsx` with no fix
// available (checked via `npm audit` before choosing).
//
// Dynamically imported a static top-level import bundled all of
// ExcelJS (~940KB) into AnalysePage's main chunk, loaded on every visit
// to the page whether or not the PM ever clicks this button. This way
// it's only fetched the first time it's actually needed.
async function downloadExcel() {
downloading.value = true
try {
const { default: ExcelJS } = await import('exceljs')
const rows = getRowsForTabType(props.tabType)
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Bettersight Analysis')
worksheet.addRow(['Field', ...props.results.map((item) => item.name)])
rows.forEach((row) => {
worksheet.addRow([
formatLabel(row.label, props.companyName),
...props.results.map((item) => rawValue(row, item) ?? ''),
])
})
worksheet.getRow(1).font = { bold: true }
worksheet.columns.forEach((col) => { col.width = 24 })
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `bettersight-analysis-${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
URL.revokeObjectURL(url)
} finally {
downloading.value = false
}
}
</script>
<template>
@ -49,6 +96,9 @@ async function copyRows() {
<UButton color="primary" variant="outline" icon="i-lucide-clipboard-copy" @click="copyRows">
{{ copied ? 'Copied ✓' : 'Copy rows' }}
</UButton>
<UButton color="neutral" variant="outline" icon="i-lucide-download" :loading="downloading" @click="downloadExcel">
Download Excel
</UButton>
</div>
<p style="font-size:12px;color:var(--t3);margin:0;line-height:1.5;">
Your results are saved. To pull them directly into your Sheet: open your

View File

@ -29,5 +29,16 @@ function timeAgo(ts) {
class="pill danger"
>{{ weakness }}</span>
</div>
<div v-if="battlecard.content_json?.pricing_observation" class="bc-pricing">
💰 {{ battlecard.content_json.pricing_observation }}
</div>
</div>
</template>
<style scoped>
.bc-pricing {
margin-top: 8px;
font-size: 12.5px;
color: var(--t2);
}
</style>

View File

@ -0,0 +1,55 @@
# Bettersight — Tier Feature List
## Analyse — $349/month (MVP tier)
- **Dashboard** — primary interface for running analysis and viewing results (no Sheet-side analysis UI)
- **Trip-intent matching** — PM describes destination/duration/style, Firecrawl `/map` finds competitor trip URLs on demand, with a confirm/swap screen before any scrape runs
- **On-demand competitor analysis** — side-by-side dashboard results, with fast path (serve from PocketBase) vs refresh path (live scrape) depending on freshness
- **Pull results into Google Sheets** — standalone Apps Script sidebar (installed once, works in any workbook the PM opens): click "Pull latest results" inside the Sheet itself to fetch the PM's most recent completed analysis from Flask and write it into the selected tab, with header-based column detection so it survives the PM renaming/reordering columns; Sheet-initiated, since a stateless backend has no way to reach into whichever Sheet is currently open — a distinct feature from the "Copy rows" clipboard fallback
- **Per-seat trip watchlist** — each seat can pin 1 specific trip (from a completed analysis) to their own personal watchlist slot; the nightly cron re-checks exactly those pinned trips instead of scraping every competitor's homepage, so nightly volume is small and predictable (seats × 1) and every resulting alert is about a trip someone explicitly chose to track, not homepage noise
- **Auto-generated battlecards** — regenerated after each scrape that produces genuinely fresh data
- **In-dashboard notifications** — activity feed for price/product changes (including watchlist-driven nightly checks), plus the weekly Monday HTML email brief via Resend
Not in MVP: Discover/Intelligence/Enterprise tier features (scheduled catalogue crawl, OSINT signal pipeline, custom fields, Slack/Teams/WhatsApp). Comparable trip discovery (semantic similarity) is a Phase 2 add-on *within* Analyse.
---
## Discover — $499/month
Everything in Analyse, plus proactive surveillance:
- **Bigger per-seat watchlist** — cap raised from 1 to 2 pinned trips per seat, layered on top of (not instead of) auto-discovery below
- **Full catalogue auto-discovery** — Firecrawl `/map` runs weekly per competitor (not just on-demand), Sunday night via n8n — finds trip pages a PM never knew to look for or pin, closing the gap the watchlist alone can't (you can only watch a trip you've already found)
- **New trip detection** — flags competitor products added since the last crawl, auto-queued for scraping without PM involvement
- **Catalogue completeness** — trip counts per competitor per destination
- **Destination gap analysis** — destinations competitors cover that the client doesn't
- **Monday brief upgrade** — now includes all auto-discovered new products, not just watchlisted ones
- **External notifications** — PM-connected Slack webhook, Microsoft Teams webhook, WhatsApp via Twilio
Pitch: *"Tell me everything that changed this week that I didn't know to ask about"* — system runs autonomously, catches unknown unknowns.
---
## Intelligence — $999/month
Everything in Discover, plus predictive signals:
- **Largest per-seat watchlist** — cap raised to 3 pinned trips per seat, on top of full auto-discovery (same as Discover) — the watchlist becomes "pin your top priorities to double-check nightly," not the primary discovery mechanism
- **Full OSINT signal pipeline** — 15+ sources (job postings, headcount, funding, SEO/PPC, domain registrations, reviews, news, app store updates, etc.)
- **Competitor Trajectory Score** — weighted weekly signal aggregation, mapped to expansion/holding/contracting labels
- **Wayback Machine positioning history** — tracks homepage/pricing messaging changes over months via the CDX API
- **Network mapping** — leadership, board, and investor connections; cross-competitor overlap detection
- **Monthly narrative report** — AI-generated predicted moves with confidence ratings
- **Early warning alerts** — Gotify-fired on significant signal clusters
Pitch: *"Tell me what my competitors are about to do"* — 36 month predictive forecasting, not just detection.
---
## Enterprise — $1,499/month (unlisted)
Everything in Intelligence, plus: dedicated onboarding, custom signal sources, quarterly strategy briefing call, SLA on scrape freshness/alerts, custom branded PDF reports. Per-seat watchlist cap: 3 (same as Intelligence — not a differentiator at this tier).
---
**Build order note:** per CLAUDE.md §27, none of Discover/Intelligence/Enterprise gets built until Analyse has paying clients — this is spec-ahead-of-build. The per-seat watchlist caps for Discover/Intelligence/Enterprise (2/3/3) are placeholders, easy to revisit before those tiers actually ship.