Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b69e70a
[NightlyBuildAgent] Fear hook on FindJobsPage no-scan state, WinStats…
claude Aug 24, 2026
cb0eada
[vault] changelog 2026-08-24 and daily to-do update
claude Aug 24, 2026
e0ead09
Fix race condition in WinStatsBanner — abort stale in-flight requests…
claude Aug 24, 2026
92dd758
Fix WinStatsBanner showing stale area stats when postcode is cleared
claude Aug 24, 2026
fa20291
fix(wins-stats): filter won rows in DB before limit to avoid false ze…
claude Aug 24, 2026
fbcffc3
fix(WinStatsBanner): clear stale stats before fetching new postcode
claude Aug 24, 2026
8559879
fix(wins-stats): aggregate count+sum in DB to avoid PostgREST row cap
claude Aug 24, 2026
9109c33
fix(wins-stats): alias PostgREST aggregates so response keys match reads
claude Aug 24, 2026
caf166b
fix(wins-stats): derive postcode area from leading letters, not fixed…
claude Aug 24, 2026
cbe6a37
fix(wins-stats): anchor area query to digit boundary, not bare prefix
claude Aug 24, 2026
0420434
fix(wins-stats): reject empty area and suppress small-area stats
claude Aug 24, 2026
e2a23fe
fix(wins-stats): expose suppressed state so small areas don't read as…
claude Aug 24, 2026
3eb24c8
fix(wins-stats): distinct-user k-anonymity, invalid area and unconfig…
claude Aug 24, 2026
bfd5d66
fix(wins-stats): reject unknown UK areas and paginate distinct-user c…
claude Aug 24, 2026
301486f
Fix wins/stats: reject invalid UK postcode areas via exact allowlist
claude Aug 24, 2026
a318d8b
Complete UK area allowlist; reorder distinct-check before aggregate
claude Aug 24, 2026
5455580
scope wins aggregate to identified contributors
claude Aug 24, 2026
015ebe4
scope wins stats to contributors with a recorded value
claude Aug 24, 2026
74dbb5e
separate win-existence and value-publish k=3 gates
claude Aug 24, 2026
4b4a08c
fix(wins): require positive won_value for k=3 value gate
claude Aug 24, 2026
9b9c53e
perf(wins): key stats effect on parsed outward, not raw postcode
claude Aug 24, 2026
989d605
fix(wins): parse outward from compact postcodes for effect key
claude Aug 24, 2026
8af8310
fix(wins): rate-limit stats route; key effect on parsed area
claude Aug 24, 2026
121949e
fix(wins): isolate stats rate-limit from scan quota
claude Aug 24, 2026
6e9aa27
fix: qualify competitor-timing copy in no-scan state
claude Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions Obsidian_Memory/Obsidian_Vault/JobFilter/Changelog 2026-08-24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Changelog 2026-08-24 — NightlyBuildAgent

## Build Status
- npm run build: PASS
- npx tsc --noEmit: PASS (0 errors)
- Dependencies: Fresh install required (node_modules absent in remote container)

## Phase 1 — Fix Broken
- No broken builds or TypeScript errors found after fresh npm install

## Phase 2 — Features
All Tier 1 features already built in prior sessions:
- Scan counter (weekly reset, Monday midnight): DONE
- Google Calendar ICS export: DONE
- WinStatsBanner (/api/wins/stats): DONE
- WhatsApp templates (quick_quote_offer + availability_check): DONE

**New this run:**
- WinStatsBanner zero-state: Component now shows placeholder when `wonCount === 0` — "No wins logged in your area yet — be the first to track a job won." Previously returned null silently.

## Phase 3 — Copy Polish

### FindJobsPage.tsx (no-scan state)
- Removed: SVG map illustration (decorative noise, slows comprehension)
- Removed: Background dot pattern (SVG data URI)
- Removed: "READY?" micro-label + generic "CHECK THE CURRENT PUBLIC-TENDER FEED." headline
- Added: "YOUR PATCH RIGHT NOW" micro-label
- Added: Fear hook headline — "EVERY WEEK YOU DON'T SCAN IS WORK YOUR COMPETITORS ARE PRICING."
- Added: Proof line — "Planning approvals, energy ratings, contract notices — scored for your trade before Checkatrade, Bark, or MyBuilder list the same job."
- Added: "Takes 10 seconds · No credit card required" footer note
- Result: Reads in <2 seconds. Fear → proof → control. No fluff.

### TrustCenterPage.tsx (accuracy fix)
- Removed: "One exclusive territory lock — your patch, nobody else in it" (false — any trade can scan any postcode)
- Added: "Your scan is private — other trades scanning the same postcode see nothing of yours" (accurate + still compelling)

## Phase 4 — Site Health

### NEEDLE — Top 3 UX issues found
1. FindJobsPage "no scan yet" state used a generic SVG map and corporate headline that wouldn't hook a tradesman — fixed this run
2. TrustCenterPage "exclusive territory lock" claim was factually wrong — could damage trust — fixed this run
3. WinStatsBanner disappeared silently with zero wins, missing an opportunity to introduce win-tracking concept to new users — fixed this run

### BUILDER — Fixes applied
All three issues fixed in this run.

### CRITIC — Clearer in <3 seconds?
- YES: "Every week you don't scan is work your competitors are pricing" is scannable and actionable
- YES: TrustCenterPage now makes an accurate claim that still builds confidence

### REVENUE — Increases likelihood of £39/month?
- YES: Fear hook drives scan action → see value → convert
- YES: Trust accuracy prevents late-stage doubt when users investigate further

## Commit
- Branch: nightly/2026-08-24-copy-ux
- PR: https://github.com/manazoid4/JobFilterV1/pull/504

## Next Run — Top 3 Priorities
1. Copy polish: ForYourTradePage bottom CTA section — test the WaitlistForm component is wired (check /api/waitlist endpoint)
2. Feature: Trade-specific scoring UX — electrician badge labels should emphasise EV charger/rewire/EICR explicitly; currently parsing `reasons` array which is good but the fallback label "Verified signal" is too vague
3. Review PR #504 CI — if check passes, merge to main
32 changes: 18 additions & 14 deletions Obsidian_Memory/Obsidian_Vault/JobFilter/Sessions/Daily To-Do.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
# Daily To-Do — JobFilter

Last updated: 2026-08-22 (NightlyBuildAgent)
Last updated: 2026-08-24 (NightlyBuildAgent)

## Completed This Run
- [x] npm run build — green
- [x] npx tsc --noEmit — clean
- [x] HomePage hero copy — fear→proof→control, competitor names in ops strip
- [x] PricingPage CTAs — removed "START AFTER COVERAGE CHECK" friction, clear pricing
- [x] PR #499 opened — nightly/2026-08-22-copy-polish
- [x] Codex P1 (30-day money-back vs LegalPage) — removed refund promise (commit 29b59c8)
- [x] Codex P2 (can win overstatement) — changed to "worth pursuing" (commit 29b59c8)
- [x] Codex P1 round-2 (pilot promises ungated features) — restored activation caveat (commit 0ac934d)
- [x] Codex P2 round-2 (empty result too categorical) — qualified for partial feed failure (commit 0ac934d)
- [x] All 4 Codex review threads replied to on PR #499
- [x] FindJobsPage "no scan yet" state — fear hook headline + removed SVG map illustration
- [x] WinStatsBanner zero-state — show placeholder when wonCount = 0
- [x] TrustCenterPage accuracy fix — removed false "exclusive territory lock" claim
- [x] PR #504 opened — nightly/2026-08-24-copy-ux

## Completed Previous Runs
- [x] HomePage hero copy — fear→proof→control, competitor names in ops strip (PR #499)
- [x] PricingPage CTAs — removed "START AFTER COVERAGE CHECK" friction, clear pricing (PR #499)
- [x] Scan counter (weekly reset, localStorage-based): DONE — FindJobsPage.tsx
- [x] WinStatsBanner with /api/wins/stats endpoint: DONE
- [x] Google Calendar ICS export: DONE — calendarExport.ts + LeadDetailPage.tsx
- [x] WhatsApp templates (quick_quote_offer + availability_check): DONE — chaseTemplates.ts

## In Progress
- [ ] PR #499 — awaiting CI "check" status on commit 0ac934d before merge
- [ ] PR #504 — awaiting CI "check" status

## Next Run Priorities
1. Copy polish: ForYourTradePage.tsx — trade-specific fear hooks and competitor callouts
2. FindJobsPage "no scan yet" state — stronger fear hook, replace generic SVG map text
3. Check if /api/wins/stats has any real data yet — WinStatsBanner only shows when wonCount > 0; if no data, add a placeholder message like "Be the first to log a win in your area"
1. Review PR #504 CI — merge if green
2. Copy polish: ForYourTradePage bottom CTA — verify WaitlistForm is wired to /api/waitlist (no fake flows)
3. Trade-specific scoring UX: fallback "Verified signal" badge label on lead cards is too vague — improve to show a trade-specific reason even without a parsed tradeMatch reason

## Known Issues
- node_modules not committed (expected) — fresh install needed each remote session
- Obsidian vault was absent this session — recreated from scratch
- Main branch protected: requires "check" CI status — all agent pushes must go via PR
- Obsidian vault Product docs absent from this repo clone (roadmap, key problems, design direction files were not found — vault only has Changelog + Sessions)
32 changes: 26 additions & 6 deletions server/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import type { Request, Response, NextFunction } from 'express';

const hits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60_000;
const LIMIT = 20;

export function rateLimit(req: Request, res: Response, next: NextFunction) {
function getIp(req: Request): string {
const forwardedFor = req.headers['x-forwarded-for'];
const forwardedIp = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
const ip = String(forwardedIp ?? req.socket.remoteAddress ?? 'unknown')
return String(forwardedIp ?? req.socket.remoteAddress ?? 'unknown')
.split(',')[0]
.trim();
}

export function makeRateLimit(limit = LIMIT): (req: Request, res: Response, next: NextFunction) => void {
const hits = new Map<string, { count: number; resetAt: number }>();
return function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
const ip = getIp(req);
const now = Date.now();
const current = hits.get(ip);
if (!current || now > current.resetAt) {
hits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return next();
}
if (current.count >= limit) {
return res.status(429).json({ ok: false, error: 'rate limit exceeded. retry in one minute.' });
}
current.count += 1;
return next();
};
}

const hits = new Map<string, { count: number; resetAt: number }>();

export function rateLimit(req: Request, res: Response, next: NextFunction) {
const ip = getIp(req);
const now = Date.now();
const current = hits.get(ip);

if (!current || now > current.resetAt) {
hits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return next();
}

if (current.count >= LIMIT) {
return res.status(429).json({
ok: false,
Expand All @@ -29,7 +50,6 @@ export function rateLimit(req: Request, res: Response, next: NextFunction) {
errors: ['rate limit exceeded. retry in one minute.'],
});
}

current.count += 1;
return next();
}
112 changes: 97 additions & 15 deletions server/routes/outcomeReport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Express, Request, Response } from 'express';
import { supabase } from '../lib/supabase';
import { resolveRequestAccess, type RequestAccess } from '../lib/requestAuth';
import { outwardFromPostcode, isKnownUkArea } from '../utils/postcode';
import { rateLimit, makeRateLimit } from '../middleware/rateLimit';

const rateLimitStats = makeRateLimit(60);

const OUTCOME_STATUSES = new Set([
'delivered',
Expand Down Expand Up @@ -59,29 +63,39 @@ export function registerOutcomeReportRoute(app: Express) {
}
});

app.get('/api/wins/stats', async (req: Request, res: Response) => {
app.get('/api/wins/stats', rateLimitStats, async (req: Request, res: Response) => {
try {
const rows = await readOutcomeRows();
const postcodePrefix = String(req.query.postcode || '').toUpperCase().slice(0, 4).trim();
const areaPrefix = postcodePrefix.slice(0, 2);
const won = rows.filter((o) => {
if (o.status !== 'won') return false;
if (!areaPrefix || !o.postcode_outward) return true;
return String(o.postcode_outward).toUpperCase().startsWith(areaPrefix);
});
if (!supabase) {
return res.status(503).json({ ok: false, error: 'Supabase is not configured; stats are unavailable.' });
}
const outward = outwardFromPostcode(String(req.query.postcode || ''));
const areaPrefix = outward.match(/^[A-Z]+/)?.[0] ?? '';
if (!areaPrefix || !isKnownUkArea(areaPrefix)) {
return res.json({ ok: true, available: false });
Comment thread
manazoid4 marked this conversation as resolved.
}
const { wonCount: totalWonCount, totalValue, suppressed, valueSuppressed } = await readWonStatsByArea(areaPrefix);
Comment thread
manazoid4 marked this conversation as resolved.

const totalWonCount = won.length;
const totalValue = won.reduce((sum, o) => sum + Number(o.won_value ?? 0), 0);
let message: string;
if (suppressed) {
message = 'Wins logged in your area — details stay private until more trades track jobs. Be the next to log one.';
} else if (totalWonCount > 0 && !valueSuppressed && totalValue > 0) {
message = `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area won jobs worth £${totalValue.toLocaleString()} via JobFilter`;
} else if (totalWonCount > 0) {
message = `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area logged wins via JobFilter`;
} else {
message = 'Be the first trade in your area to log a win.';
}

return res.json({
ok: true,
postcodeArea: postcodePrefix || 'UK',
available: true,
postcodeArea: areaPrefix,
wonCount: totalWonCount,
totalValue,
totalValueFormatted: `£${totalValue.toLocaleString()}`,
message: totalWonCount > 0
? `${totalWonCount} trade${totalWonCount === 1 ? '' : 's'} in your area won jobs worth £${totalValue.toLocaleString()} via JobFilter`
: 'Be the first trade in your area to log a win.',
suppressed,
valueSuppressed,
message,
});
} catch (error: any) {
return res.status(500).json({ ok: false, error: String(error?.message ?? 'Stats failed.') });
Expand Down Expand Up @@ -210,6 +224,74 @@ async function leadIsOwnedBy(leadId: string, userId: string) {
return data?.user_id === userId;
}

const SMALL_AREA_PRIVACY_THRESHOLD = 3;

async function readWonStatsByArea(areaPrefix: string): Promise<{ wonCount: number; totalValue: number; suppressed: boolean; valueSuppressed: boolean }> {
const client = supabase!;
const areaFilter = `^${areaPrefix}[0-9]`;

const distinctWinContributors = await countDistinctContributors(areaFilter, SMALL_AREA_PRIVACY_THRESHOLD, { requireValue: false });
if (distinctWinContributors === 0) {
return { wonCount: 0, totalValue: 0, suppressed: false, valueSuppressed: false };
}
if (distinctWinContributors < SMALL_AREA_PRIVACY_THRESHOLD) {
return { wonCount: 0, totalValue: 0, suppressed: true, valueSuppressed: true };
}

const distinctValueContributors = await countDistinctContributors(areaFilter, SMALL_AREA_PRIVACY_THRESHOLD, { requireValue: true });
const valueSuppressed = distinctValueContributors < SMALL_AREA_PRIVACY_THRESHOLD;

const { data: countData, error: countError } = await (client as any)
.from('lead_outcomes')
.select('won_count:count()')
.eq('status', 'won')
.filter('postcode_outward', 'imatch', areaFilter)
.not('user_id', 'is', null);
if (countError) throw new Error(countError.message);
const wonCount = Number((countData as any)?.[0]?.won_count ?? 0);

let totalValue = 0;
if (!valueSuppressed) {
const { data: sumData, error: sumError } = await (client as any)
.from('lead_outcomes')
.select('won_value_sum:won_value.sum()')
.eq('status', 'won')
.filter('postcode_outward', 'imatch', areaFilter)
.not('user_id', 'is', null)
.gt('won_value', 0);
if (sumError) throw new Error(sumError.message);
totalValue = Number((sumData as any)?.[0]?.won_value_sum ?? 0);
}

return { wonCount, totalValue, suppressed: false, valueSuppressed };
}

async function countDistinctContributors(areaFilter: string, target: number, opts: { requireValue: boolean }): Promise<number> {
const client = supabase!;
const seen: string[] = [];
for (let i = 0; i < target; i++) {
let query: any = (client as any)
.from('lead_outcomes')
.select('user_id')
.eq('status', 'won')
.filter('postcode_outward', 'imatch', areaFilter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid asserting zero when wins lack outward postcodes

When a win comes from a feed without an exact postcode, deriveOutward() stores UK (leadEngine/normaliser.ts:150-158), and FindJobsPage forwards that value unchanged when recording the outcome (src/pages/FindJobsPage.tsx:1405-1418). This area regex excludes every such win from the contributor check, so an area whose tracked wins came from those supported contract feeds receives distinctWinContributors === 0 and the new banner states “No wins logged,” even though the database contains wins that cannot be geographically attributed. Preserve the user's searched outward when logging the outcome, or treat unlocalized outcome data as unavailable rather than an exhaustive zero.

Useful? React with 👍 / 👎.

.not('user_id', 'is', null)
Comment thread
manazoid4 marked this conversation as resolved.
.limit(1);
if (opts.requireValue) {
query = query.gt('won_value', 0);
}
if (seen.length > 0) {
query = query.not('user_id', 'in', `(${seen.join(',')})`);
}
const { data, error } = await query;
if (error) throw new Error(error.message);
const userId = (data as any)?.[0]?.user_id;
if (!userId) return seen.length;
seen.push(userId);
}
return seen.length;
}

async function readOutcomeRows() {
if (!supabase) return [] as any[];
const { data, error } = await supabase
Expand Down
29 changes: 29 additions & 0 deletions server/utils/postcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@ const UK_POSTCODE =
/^([A-Z]{1,2}\d[A-Z\d]?)\s*(\d[A-Z]{2})$/i;
const UK_OUTWARD = /^([A-Z]{1,2}\d[A-Z\d]?)$/i;

const KNOWN_UK_AREAS = new Set<string>([
'AB', 'AL', 'B', 'BA', 'BB', 'BD', 'BF', 'BH', 'BL', 'BN', 'BR', 'BS', 'BT',
'CA', 'CB', 'CF', 'CH', 'CM', 'CO', 'CR', 'CT', 'CV', 'CW',
'DA', 'DD', 'DE', 'DG', 'DH', 'DL', 'DN', 'DT', 'DY',
'E', 'EC', 'EH', 'EN', 'EX',
'FK', 'FY',
'G', 'GL', 'GU', 'GY',
'HA', 'HD', 'HG', 'HP', 'HR', 'HS', 'HU', 'HX',
'IG', 'IM', 'IP', 'IV',
'JE',
'KA', 'KT', 'KW', 'KY',
'L', 'LA', 'LD', 'LE', 'LL', 'LN', 'LS', 'LU',
'M', 'ME', 'MK', 'ML',
'N', 'NE', 'NG', 'NN', 'NP', 'NR', 'NW',
'OL', 'OX',
'PA', 'PE', 'PH', 'PL', 'PO', 'PR',
'RG', 'RH', 'RM',
'S', 'SA', 'SE', 'SG', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SR', 'SS', 'ST', 'SW', 'SY',
'TA', 'TD', 'TF', 'TN', 'TQ', 'TR', 'TS', 'TW',
'UB',
'W', 'WA', 'WC', 'WD', 'WF', 'WN', 'WR', 'WS', 'WV',
'YO',
'ZE',
]);

export function isKnownUkArea(area: string) {
return KNOWN_UK_AREAS.has(area.toUpperCase());
}

const REGION_BY_PREFIX: Array<[RegExp, string]> = [
[/^(BT)/, 'Northern Ireland'],
[/^(B|CV|DY|WS|WV)/, 'West Midlands'],
Expand Down
Loading