diff --git a/Obsidian_Memory/Obsidian_Vault/JobFilter/Changelog 2026-08-24.md b/Obsidian_Memory/Obsidian_Vault/JobFilter/Changelog 2026-08-24.md new file mode 100644 index 00000000..fec9d5f6 --- /dev/null +++ b/Obsidian_Memory/Obsidian_Vault/JobFilter/Changelog 2026-08-24.md @@ -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 diff --git a/Obsidian_Memory/Obsidian_Vault/JobFilter/Sessions/Daily To-Do.md b/Obsidian_Memory/Obsidian_Vault/JobFilter/Sessions/Daily To-Do.md index ceb0ffc9..315391ca 100644 --- a/Obsidian_Memory/Obsidian_Vault/JobFilter/Sessions/Daily To-Do.md +++ b/Obsidian_Memory/Obsidian_Vault/JobFilter/Sessions/Daily To-Do.md @@ -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) diff --git a/server/middleware/rateLimit.ts b/server/middleware/rateLimit.ts index 0b66b441..9f78e12f 100644 --- a/server/middleware/rateLimit.ts +++ b/server/middleware/rateLimit.ts @@ -1,23 +1,44 @@ import type { Request, Response, NextFunction } from 'express'; -const hits = new Map(); 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(); + 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(); + +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, @@ -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(); } diff --git a/server/routes/outcomeReport.ts b/server/routes/outcomeReport.ts index 101214d9..4e39c4ad 100644 --- a/server/routes/outcomeReport.ts +++ b/server/routes/outcomeReport.ts @@ -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', @@ -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 }); + } + const { wonCount: totalWonCount, totalValue, suppressed, valueSuppressed } = await readWonStatsByArea(areaPrefix); - 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.') }); @@ -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 { + 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) + .not('user_id', 'is', null) + .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 diff --git a/server/utils/postcode.ts b/server/utils/postcode.ts index a7a19b54..035981b6 100644 --- a/server/utils/postcode.ts +++ b/server/utils/postcode.ts @@ -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([ + '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'], diff --git a/src/components/WinStatsBanner.tsx b/src/components/WinStatsBanner.tsx index b1526c4d..0deeb84a 100644 --- a/src/components/WinStatsBanner.tsx +++ b/src/components/WinStatsBanner.tsx @@ -1,26 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { TrendingUp } from 'lucide-react'; interface WinStats { wonCount: number; totalValueFormatted: string; message: string; + suppressed?: boolean; } export function WinStatsBanner({ postcode }: { postcode: string }) { const [stats, setStats] = useState(null); + const [loaded, setLoaded] = useState(false); + + const area = useMemo(() => { + const cleaned = postcode.toUpperCase().replace(/[^A-Z0-9]/g, ''); + return cleaned.match(/^([A-Z]{1,2})(?=\d)/)?.[1] ?? ''; + }, [postcode]); useEffect(() => { - const outward = postcode.trim().split(' ')[0].toUpperCase(); - fetch(`/api/wins/stats?postcode=${encodeURIComponent(outward)}`) + if (!area) { + setStats(null); + setLoaded(false); + return; + } + setStats(null); + setLoaded(false); + const controller = new AbortController(); + fetch(`/api/wins/stats?postcode=${encodeURIComponent(`${area}1`)}`, { signal: controller.signal }) .then((r) => r.json()) .then((data) => { - if (data.ok && data.wonCount > 0) setStats(data); + if (data.ok && data.available !== false) setStats(data); + setLoaded(true); }) - .catch(() => {}); - }, [postcode]); + .catch((err: unknown) => { + if (err instanceof Error && err.name !== 'AbortError') setLoaded(true); + }); + return () => controller.abort(); + }, [area]); + + if (!loaded || !stats) return null; + + if (stats.suppressed) { + return ( +
+ +

{stats.message}

+
+ ); + } - if (!stats) return null; + if (stats.wonCount === 0) { + return ( +
+ +

No wins logged in your area yet — be the first to track a job won.

+
+ ); + } return (
diff --git a/src/pages/FindJobsPage.tsx b/src/pages/FindJobsPage.tsx index ae000033..81168e1f 100644 --- a/src/pages/FindJobsPage.tsx +++ b/src/pages/FindJobsPage.tsx @@ -876,26 +876,15 @@ export function FindJobsPage() { {/* ── NO SCAN YET — PROMPT ───────────────────────────────────── */} {!hasScanned && !loading && !fillWeekLoading && ( -
- {/* Empty map illustration */} -
- - - - - - - - - NO SIGNALS YET - -
-

READY?

-

CHECK THE CURRENT PUBLIC-TENDER FEED.

-

- Tap a trade above or enter your postcode. Takes 10 seconds. No credit card required. +

+

YOUR PATCH RIGHT NOW

+

+ EVERY WEEK YOU DON'T SCAN IS WORK YOUR COMPETITORS ARE PRICING. +

+

+ Planning approvals, energy ratings, contract notices — scored for your trade from official sources, before the job reaches the quote sites.

-
+
+

Takes 10 seconds · No credit card required

)} diff --git a/src/pages/TrustCenterPage.tsx b/src/pages/TrustCenterPage.tsx index 97d0973a..d769e7cb 100644 --- a/src/pages/TrustCenterPage.tsx +++ b/src/pages/TrustCenterPage.tsx @@ -52,8 +52,8 @@ const privacyPoints = [ ]; const guaranteeFeatures = [ - 'One exclusive territory lock — your patch, nobody else in it', - 'Unlimited lead alerts within locked territory', + 'Your scan is private — other trades scanning the same postcode see nothing of yours', + 'Unlimited lead alerts within your patch', 'Unlimited WhatsApp alerts', 'Letter drop scripts for every lead — print and post in minutes', 'Full lead scoring + lead readiness markers',