Heatmap Value Filter Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add a flexible value filter with 7 modes (=, ≠, >, ≥, <, ≤, Range) to filter PM10 data points in the Heatmap.
Architecture: Extend HeatmapControlsState with filter properties, add filtering logic to filteredRows useMemo, and create a collapsible UI section with button group and input fields.
Tech Stack: React, TypeScript, existing UI patterns (button group, toggle switch, input fields)
Task 1: Add Type Definitions
Files:
- Modify:
src/features/heatmap/types.ts
Step 1: Add ValueFilterMode type
Add after the existing type definitions (after line 7):
export type ValueFilterMode = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'range';Step 2: Extend HeatmapControlsState
Add the new properties at the end of HeatmapControlsState (before the closing }):
// Data filtering
hideZeroValues: boolean;
// Value filter
valueFilterEnabled: boolean;
valueFilterMode: ValueFilterMode;
valueFilterValue: number | null;
valueFilterMin: number | null;
valueFilterMax: number | null;Step 3: Commit
git add src/features/heatmap/types.ts
git commit -m "feat(heatmap): add ValueFilterMode type and extend HeatmapControlsState"Task 2: Add Default Values
Files:
- Modify:
src/features/heatmap/constants.ts
Step 1: Update DEFAULT_CONTROLS
Add the new default values at the end of DEFAULT_CONTROLS (after hideZeroValues: false):
// Data filtering
hideZeroValues: false, // Show all data by default
// Value filter
valueFilterEnabled: false,
valueFilterMode: 'eq' as const,
valueFilterValue: null,
valueFilterMin: null,
valueFilterMax: null,Step 2: Verify TypeScript compiles
Run: pnpm build:check Expected: PASS
Step 3: Commit
git add src/features/heatmap/constants.ts
git commit -m "feat(heatmap): add value filter defaults to DEFAULT_CONTROLS"Task 3: Update Filter Logic
Files:
- Modify:
src/app/(admin)/(pages)/heatmap/index.tsx
Step 1: Update filteredRows useMemo
Replace the existing filteredRows useMemo with the enhanced version that includes value filtering:
// Filter out zero values and apply value filter
const filteredRows = useMemo(() => {
let result = rows;
// 1. Apply hideZeroValues filter first
if (controls.hideZeroValues) {
result = result.filter((row) => {
const value = parseFloat(row.Location_AssetValue);
return !isNaN(value) && value > 0;
});
}
// 2. Apply value filter if enabled and has valid value
if (controls.valueFilterEnabled) {
const filterValue = controls.valueFilterValue;
const filterMin = controls.valueFilterMin;
const filterMax = controls.valueFilterMax;
const mode = controls.valueFilterMode;
// Check if filter has valid values
const hasValidSingleValue = filterValue !== null && !isNaN(filterValue);
const hasValidRangeValues = filterMin !== null && filterMax !== null &&
!isNaN(filterMin) && !isNaN(filterMax) &&
filterMin <= filterMax;
if ((mode !== 'range' && hasValidSingleValue) || (mode === 'range' && hasValidRangeValues)) {
result = result.filter((row) => {
const value = parseFloat(row.Location_AssetValue);
if (isNaN(value)) return false;
switch (mode) {
case 'eq': return value !== filterValue; // Filter out equals
case 'neq': return value === filterValue; // Filter out not equals (keep equals)
case 'gt': return value <= filterValue!; // Filter out greater than
case 'gte': return value < filterValue!; // Filter out greater than or equal
case 'lt': return value >= filterValue!; // Filter out less than
case 'lte': return value > filterValue!; // Filter out less than or equal
case 'range': return value < filterMin! || value > filterMax!; // Keep only in range
default: return true;
}
});
}
}
return result;
}, [rows, controls.hideZeroValues, controls.valueFilterEnabled,
controls.valueFilterMode, controls.valueFilterValue,
controls.valueFilterMin, controls.valueFilterMax]);Step 2: Verify TypeScript compiles
Run: pnpm build:check Expected: PASS
Step 3: Commit
git add "src/app/(admin)/(pages)/heatmap/index.tsx"
git commit -m "feat(heatmap): add value filter logic to filteredRows useMemo"Task 4: Add Value Filter UI
Files:
- Modify:
src/app/(admin)/(pages)/heatmap/index.tsx
Step 1: Add validation state
After the existing state declarations (around line 140), add:
const [valueFilterError, setValueFilterError] = useState<string | null>(null);Step 2: Add validation effect
Add a useEffect to validate the filter inputs:
// Validate value filter inputs
useEffect(() => {
if (!controls.valueFilterEnabled) {
setValueFilterError(null);
return;
}
if (controls.valueFilterMode === 'range') {
if (controls.valueFilterMin === null || controls.valueFilterMax === null) {
setValueFilterError('Please enter valid numbers');
} else if (controls.valueFilterMin > controls.valueFilterMax) {
setValueFilterError('Min must be less than or equal to Max');
} else {
setValueFilterError(null);
}
} else {
if (controls.valueFilterValue === null) {
setValueFilterError('Please enter a valid number');
} else {
setValueFilterError(null);
}
}
}, [controls.valueFilterEnabled, controls.valueFilterMode,
controls.valueFilterValue, controls.valueFilterMin, controls.valueFilterMax]);Step 3: Add helper for counting value-filtered points
Add this after the validation effect:
// Calculate how many points are filtered by value filter only
const valueFilteredCount = useMemo(() => {
if (!controls.valueFilterEnabled) return 0;
// Start from rows after hideZeroValues filter
let baseRows = rows;
if (controls.hideZeroValues) {
baseRows = rows.filter((row) => {
const value = parseFloat(row.Location_AssetValue);
return !isNaN(value) && value > 0;
});
}
return baseRows.length - filteredRows.length;
}, [rows, filteredRows, controls.hideZeroValues, controls.valueFilterEnabled]);Step 4: Add the UI component
Find the "Hide Zero Values Toggle" section in the JSX. After the closing of that section (after the {controls.hideZeroValues && (...)} block), add the Value Filter UI:
{/* Value Filter */}
<div className="space-y-3 pt-3 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon
className="w-4 h-4 text-slate-600 dark:text-slate-400"
icon="solar:tuning-2-bold-duotone"
/>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Value Filter
</label>
</div>
<button
type="button"
role="switch"
aria-checked={controls.valueFilterEnabled}
onClick={() => updateControls({ valueFilterEnabled: !controls.valueFilterEnabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
controls.valueFilterEnabled
? "bg-blue-600"
: "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
controls.valueFilterEnabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Filter Mode Buttons */}
<div className={`flex flex-wrap gap-1 ${!controls.valueFilterEnabled ? 'opacity-50' : ''}`}>
{([
{ mode: 'eq', label: '=' },
{ mode: 'neq', label: '≠' },
{ mode: 'gt', label: '>' },
{ mode: 'gte', label: '≥' },
{ mode: 'lt', label: '<' },
{ mode: 'lte', label: '≤' },
{ mode: 'range', label: 'Range' },
] as const).map(({ mode, label }) => (
<button
key={mode}
type="button"
disabled={!controls.valueFilterEnabled}
onClick={() => updateControls({ valueFilterMode: mode })}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
controls.valueFilterMode === mode
? 'bg-blue-600 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600'
} ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
>
{label}
</button>
))}
</div>
{/* Input Fields */}
{controls.valueFilterMode === 'range' ? (
<div className={`flex gap-2 ${!controls.valueFilterEnabled ? 'opacity-50' : ''}`}>
<input
type="number"
placeholder="Min"
disabled={!controls.valueFilterEnabled}
value={controls.valueFilterMin ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value);
updateControls({ valueFilterMin: val });
}}
className={`flex-1 px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
valueFilterError && controls.valueFilterEnabled
? 'border-red-500'
: 'border-slate-300 dark:border-slate-600'
} ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
/>
<input
type="number"
placeholder="Max"
disabled={!controls.valueFilterEnabled}
value={controls.valueFilterMax ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value);
updateControls({ valueFilterMax: val });
}}
className={`flex-1 px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
valueFilterError && controls.valueFilterEnabled
? 'border-red-500'
: 'border-slate-300 dark:border-slate-600'
} ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
/>
</div>
) : (
<input
type="number"
placeholder="Enter value..."
disabled={!controls.valueFilterEnabled}
value={controls.valueFilterValue ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseFloat(e.target.value);
updateControls({ valueFilterValue: val });
}}
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
valueFilterError && controls.valueFilterEnabled
? 'border-red-500'
: 'border-slate-300 dark:border-slate-600'
} ${!controls.valueFilterEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
)}
{/* Error Message */}
{valueFilterError && controls.valueFilterEnabled && (
<p className="text-xs text-red-500">{valueFilterError}</p>
)}
{/* Filter Info */}
{controls.valueFilterEnabled && !valueFilterError && valueFilteredCount > 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Filtering out {valueFilteredCount} data points
</p>
)}
</div>Step 5: Verify TypeScript compiles
Run: pnpm build:check Expected: PASS
Step 6: Commit
git add "src/app/(admin)/(pages)/heatmap/index.tsx"
git commit -m "feat(heatmap): add Value Filter UI with mode buttons and validation"Task 5: Build Verification and Testing
Step 1: Run full build
Run: pnpm build:check Expected: PASS
Step 2: Manual testing checklist
- Navigate to Heatmap page
- Verify "Value Filter" section appears below "Hide Zero Values"
- Test toggle enable/disable - buttons and inputs should gray out when disabled
- Test each filter mode button - should highlight when selected
- Test single value input - enter a number, verify filtering works
- Test range mode - switch to Range, verify two inputs appear
- Test validation:
- Empty input shows error
- Range with Min > Max shows error
- Test with "Hide Zero Values" enabled - both filters should stack
- Verify filtered count displays correctly
Step 3: Final commit (if any fixes needed)
git add -A
git commit -m "feat(heatmap): complete Value Filter feature"Summary
This implementation adds a powerful value filter to the Heatmap with:
- 7 filter modes: =, ≠, >, ≥, <, ≤, Range
- Intuitive UI: Button group for mode selection, appropriate input fields
- Real-time validation: Error messages for invalid inputs
- Stacking filters: Works independently with "Hide Zero Values"
- User feedback: Shows count of filtered data points
Total estimated time: 25-30 minutes