diff --git a/RFCP-Iteration10.3.1-Threshold-Filter-Fix.md b/RFCP-Iteration10.3.1-Threshold-Filter-Fix.md
new file mode 100644
index 0000000..e2d7935
--- /dev/null
+++ b/RFCP-Iteration10.3.1-Threshold-Filter-Fix.md
@@ -0,0 +1,320 @@
+# RFCP - Iteration 10.3.1: Heatmap Threshold Filter Fix
+
+**Goal:** Fix purple "weak signal" still rendering by adding RSRP threshold filtering to heatmap renderer
+**Priority:** P1 (Bug Fix)
+**Estimated Time:** 15-20 minutes
+**Status:** Ready for Implementation
+
+---
+
+## π Overview
+
+Iteration 10.3 added coverage boundary and changed default Min Signal to -100 dBm, but the heatmap tile renderer still draws all points including those below threshold. This creates the misleading purple fill that should have been removed.
+
+**Root Cause:** `HeatmapTileRenderer.ts` filters points only by geographic bounds, not by RSRP threshold.
+
+---
+
+## π Current Bug
+
+**Symptom:** Purple/lavender fill visible outside the "useful" coverage zone despite Min Signal = -100 dBm
+
+**Location:** `src/components/map/HeatmapTileRenderer.ts` lines 100-107
+
+**Current Code:**
+```typescript
+// Filter relevant points
+const relevant = points.filter(
+ (p) =>
+ p.lat >= latMin - bufferDeg &&
+ p.lat <= latMax + bufferDeg &&
+ p.lon >= lonMin - bufferDeg &&
+ p.lon <= lonMax + bufferDeg
+);
+```
+
+**Problem:** No RSRP threshold check β all points render regardless of signal strength.
+
+---
+
+## β
Solution
+
+Add `rsrpThreshold` parameter to the rendering pipeline and filter out weak signals.
+
+### Step 1: Update HeatmapTileRenderer.ts
+
+**File:** `src/components/map/HeatmapTileRenderer.ts`
+
+#### 1.1 Add threshold to class state
+
+```typescript
+// Around line 35, add new property:
+export class HeatmapTileRenderer {
+ private tileSize = 256;
+ private radiusMeters: number;
+ private rsrpThreshold: number; // NEW
+
+ // LRU cache...
+
+ constructor(radiusMeters = 400, maxCacheSize = 150, rsrpThreshold = -100) {
+ this.radiusMeters = radiusMeters;
+ this.maxCacheSize = maxCacheSize;
+ this.rsrpThreshold = rsrpThreshold; // NEW
+ }
+```
+
+#### 1.2 Add setter for threshold
+
+```typescript
+// After setRadiusMeters(), add:
+
+/** Update the RSRP threshold - points below this are not rendered. */
+setRsrpThreshold(threshold: number): void {
+ if (threshold !== this.rsrpThreshold) {
+ this.rsrpThreshold = threshold;
+ this.clearCache(); // Important: invalidate cache when threshold changes
+ }
+}
+```
+
+#### 1.3 Update renderTile() filter
+
+```typescript
+// Around line 100, update the filter:
+
+// Filter relevant points - geographic bounds AND RSRP threshold
+const relevant = points.filter(
+ (p) =>
+ p.rsrp >= this.rsrpThreshold && // π₯ NEW: skip weak signals
+ p.lat >= latMin - bufferDeg &&
+ p.lat <= latMax + bufferDeg &&
+ p.lon >= lonMin - bufferDeg &&
+ p.lon <= lonMax + bufferDeg
+);
+```
+
+### Step 2: Update GeographicHeatmap.tsx
+
+**File:** `src/components/map/GeographicHeatmap.tsx`
+
+#### 2.1 Add threshold prop
+
+```typescript
+interface GeographicHeatmapProps {
+ points: HeatmapPoint[];
+ visible: boolean;
+ opacity?: number;
+ radiusMeters?: number;
+ rsrpThreshold?: number; // NEW
+}
+
+export default function GeographicHeatmap({
+ points,
+ visible,
+ opacity = 0.7,
+ radiusMeters = 400,
+ rsrpThreshold = -100, // NEW
+}: GeographicHeatmapProps) {
+```
+
+#### 2.2 Update renderer when threshold changes
+
+```typescript
+// After the useEffect for radiusMeters, add:
+
+// Update renderer threshold when prop changes
+useEffect(() => {
+ rendererRef.current.setRsrpThreshold(rsrpThreshold);
+}, [rsrpThreshold]);
+```
+
+#### 2.3 Include threshold in cache invalidation
+
+```typescript
+// Update the pointsHash useEffect to include threshold:
+
+useEffect(() => {
+ if (points.length === 0) {
+ rendererRef.current.setPointsHash('empty');
+ return;
+ }
+ const first = points[0];
+ const last = points[points.length - 1];
+ // Include threshold in hash so cache invalidates when it changes
+ const hash = `${points.length}:${first.lat.toFixed(4)},${first.lon.toFixed(4)}:${last.rsrp}:${rsrpThreshold}`;
+ rendererRef.current.setPointsHash(hash);
+}, [points, rsrpThreshold]);
+```
+
+### Step 3: Update App.tsx
+
+**File:** `src/App.tsx`
+
+Pass threshold from settings to GeographicHeatmap:
+
+```typescript
+{coverageResult && (
+ <>
+
+
+ >
+)}
+```
+
+---
+
+## π Files to Modify
+
+| File | Changes |
+|------|---------|
+| `src/components/map/HeatmapTileRenderer.ts` | Add `rsrpThreshold` property, setter, filter in renderTile() |
+| `src/components/map/GeographicHeatmap.tsx` | Add `rsrpThreshold` prop, pass to renderer, update cache hash |
+| `src/App.tsx` | Pass `settings.rsrpThreshold` to GeographicHeatmap |
+
+**Total:** 3 files, ~20 lines changed
+
+---
+
+## π§ͺ Testing Checklist
+
+### Visual Testing
+
+- [ ] **No purple fill:** Only orange gradient visible (no purple/lavender outside)
+- [ ] **Threshold respected:** Changing Min Signal slider updates heatmap immediately
+- [ ] **Boundary matches:** Dashed border aligns with heatmap edge
+- [ ] **Different thresholds:** Test -90, -100, -110 β heatmap adjusts accordingly
+
+### Functional Testing
+
+- [ ] **Slider reactive:** Moving Min Signal slider updates heatmap without "Calculate Coverage"
+- [ ] **Cache invalidation:** Threshold change clears old tiles, renders new ones
+- [ ] **Zoom levels:** Works at zoom 8, 12, 16
+- [ ] **Multiple sites:** Each site respects threshold
+
+### Regression Testing
+
+- [ ] **50m resolution:** Still works without crash
+- [ ] **Performance:** No significant slowdown
+- [ ] **Boundary component:** Still renders correctly
+- [ ] **Legend:** Still shows correct colors and threshold indicator
+
+---
+
+## ποΈ Build & Deploy
+
+```bash
+cd /opt/rfcp/frontend
+
+# Build
+npm run build
+
+# Expected: 0 TypeScript errors, 0 ESLint errors
+
+# Deploy
+sudo systemctl reload caddy
+
+# Test at https://rfcp.eliah.one
+# 1. Set Min Signal to -100 dBm
+# 2. Verify NO purple visible outside orange zone
+# 3. Move slider to -90 β coverage zone shrinks
+# 4. Move slider to -110 β coverage zone expands (some purple appears)
+```
+
+---
+
+## π Commit Message
+
+```
+fix(heatmap): filter points by RSRP threshold to remove purple fill
+
+- Added rsrpThreshold property to HeatmapTileRenderer
+- Filter points below threshold before rendering tiles
+- Cache invalidates when threshold changes
+- Passed threshold from settings through GeographicHeatmap
+
+Points below Min Signal threshold are now completely hidden,
+not just dimmed. This removes the misleading purple "weak signal"
+fill that made it look like coverage existed where it doesn't.
+
+Iteration: 10.3.1
+Fixes: Purple fill still visible after 10.3
+```
+
+---
+
+## β
Success Criteria
+
+**Must Pass:**
+1. β
No purple/lavender fill when Min Signal = -100 dBm
+2. β
Heatmap updates when Min Signal slider moves
+3. β
Only orange gradient visible (excellent β fair signal)
+4. β
Dashed boundary aligns with heatmap edge
+5. β
TypeScript: 0 errors
+6. β
ESLint: 0 errors
+
+**User Acceptance:**
+- ΠΠ»Π΅Π³ confirms: "ΡΡΠΎΠ»Π΅ΡΡ Π½Π΅ΠΌΠ°, ΡΡΠ»ΡΠΊΠΈ ΠΎΡΠ°Π½ΠΆΠ΅Π²ΠΈΠΉ Π³ΡΠ°Π΄ΡΡΠ½Ρ"
+
+---
+
+## π¨ Expected Visual Result
+
+**Before (10.3):**
+```
+βββββββββββββββββββββββββββββββ
+β βββ Purple fill ββββββββββ β β Still visible!
+β ββββ β β β β β β β ββββββ β
+β βββ Orange gradient βββββ β
+β ββββ β β β β β β β ββββββ β
+β ββββββββββββββββββββββββββ β
+βββββββββββββββββββββββββββββββ
+```
+
+**After (10.3.1):**
+```
+ ββ β β β β β ββ
+ β β
+ β β ββ€ π Orange ββ β β β Dashed border
+ β gradient β
+ β (no purple β
+ β outside!) β
+ ββ β β β β β ββ
+```
+
+---
+
+## π€ Instructions for Claude Code
+
+**Context:**
+- Project: RFCP - `/opt/rfcp/frontend/`
+- Bug: Purple fill still renders despite Min Signal = -100 dBm
+- Root cause: `HeatmapTileRenderer.ts` doesn't filter by threshold
+
+**Implementation Order:**
+1. `HeatmapTileRenderer.ts` β add threshold property + setter + filter
+2. `GeographicHeatmap.tsx` β add prop, pass to renderer, update hash
+3. `App.tsx` β pass `settings.rsrpThreshold` to component
+
+**Key Points:**
+- Threshold setter MUST call `clearCache()` to invalidate tiles
+- Include threshold in pointsHash for proper cache invalidation
+- Default threshold = -100 dBm (matches store default)
+
+**Success:** User sees only orange gradient, no purple fill outside coverage zone
+
+---
+
+**Document Created:** 2025-01-30
+**Author:** Claude (Opus 4.5) + ΠΠ»Π΅Π³
+**Status:** Ready for Implementation
+**Depends On:** Iteration 10.3 (boundary component)