Controls: separate Categories filter toggle, standalone buttons, full-width category headers

- FilterBar split into CategoryFilterBar / StatesFilterBar / StatFilterBar — each independently toggleable from the controls bar (Categories Filter / States Filter / Stats Filter), visibility persisted to localStorage
- Control buttons are now individual rounded buttons with a 6px gap (removed the merged/segmented styling that left a missing border)
- Category group headers span the full width with top/bottom borders only; 37px side padding keeps the header text aligned with room card content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:23:24 +00:00
parent a4c1f9bce5
commit 2ec629cb5b
3 changed files with 42 additions and 50 deletions

View file

@ -4,15 +4,6 @@ import type { FilterMode, FilterState, StatFilters, StatFilterMode, SemanticFlow
import { SEMANTIC_FLOW_MAP } from '../types' import { SEMANTIC_FLOW_MAP } from '../types'
import type { RoomData } from '../types' import type { RoomData } from '../types'
interface FilterBarProps {
filters: FilterState
categories: Array<{ id: string; name: string }>
rooms: RoomData[]
viewDate: string
onToggleCategory: (id: string) => void
onToggleFlow: (flow: SemanticFlowKey) => void
}
const SEMANTIC_KEYS: SemanticFlowKey[] = ['arriving', 'departing', 'back-to-back', 'staying', 'vacant', 'blocked'] const SEMANTIC_KEYS: SemanticFlowKey[] = ['arriving', 'departing', 'back-to-back', 'staying', 'vacant', 'blocked']
const SEMANTIC_LABELS: Record<SemanticFlowKey, string> = { const SEMANTIC_LABELS: Record<SemanticFlowKey, string> = {
arriving: 'Arriving', departing: 'Departing', 'back-to-back': 'B2B', arriving: 'Arriving', departing: 'Departing', 'back-to-back': 'B2B',
@ -34,7 +25,14 @@ function countBySemantic(rooms: RoomData[], key: SemanticFlowKey) {
return rooms.filter(r => flowTypes.includes(r.flow_type)).length return rooms.filter(r => flowTypes.includes(r.flow_type)).length
} }
export function FilterBar({ filters, categories, rooms, viewDate, onToggleCategory, onToggleFlow }: FilterBarProps) { interface CategoryFilterBarProps {
filters: FilterState
categories: Array<{ id: string; name: string }>
rooms: RoomData[]
onToggleCategory: (id: string) => void
}
export function CategoryFilterBar({ filters, categories, rooms, onToggleCategory }: CategoryFilterBarProps) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const scroll = (dir: 'left' | 'right') => { const scroll = (dir: 'left' | 'right') => {
@ -43,7 +41,6 @@ export function FilterBar({ filters, categories, rooms, viewDate, onToggleCatego
return ( return (
<div className="filter-section"> <div className="filter-section">
{/* Category filter row */}
<div className="filter-bar"> <div className="filter-bar">
<button className="filter-arrow left" onClick={() => scroll('left')}> <button className="filter-arrow left" onClick={() => scroll('left')}>
<ChevronLeft size={14} strokeWidth={1.75} /> <ChevronLeft size={14} strokeWidth={1.75} />
@ -68,9 +65,20 @@ export function FilterBar({ filters, categories, rooms, viewDate, onToggleCatego
<ChevronRight size={14} strokeWidth={1.75} /> <ChevronRight size={14} strokeWidth={1.75} />
</button> </button>
</div> </div>
</div>
)
}
{/* Flow type filter row */} interface StatesFilterBarProps {
<div className="filter-bar" style={{ borderTop: '1px solid var(--border)' }}> filters: FilterState
rooms: RoomData[]
onToggleFlow: (flow: SemanticFlowKey) => void
}
export function StatesFilterBar({ filters, rooms, onToggleFlow }: StatesFilterBarProps) {
return (
<div className="filter-section">
<div className="filter-bar">
<div className="filter-bar-scroll"> <div className="filter-bar-scroll">
{SEMANTIC_KEYS.map(key => { {SEMANTIC_KEYS.map(key => {
const mode = filters.flowTypes[key] ?? 'off' const mode = filters.flowTypes[key] ?? 'off'
@ -111,7 +119,7 @@ const STAT_LABELS: Record<keyof StatFilters, [string, string, string]> = {
export function StatFilterBar({ statFilters, onChange }: StatFilterBarProps) { export function StatFilterBar({ statFilters, onChange }: StatFilterBarProps) {
return ( return (
<div className="filter-section" style={{ borderBottom: 'none', borderTop: '1px solid var(--border)' }}> <div className="filter-section">
<div className="filter-bar"> <div className="filter-bar">
<div className="filter-bar-scroll"> <div className="filter-bar-scroll">
{(Object.keys(statFilters) as Array<keyof StatFilters>).map(key => { {(Object.keys(statFilters) as Array<keyof StatFilters>).map(key => {

View file

@ -215,7 +215,7 @@ html, body, #root {
} }
.control-group { .control-group {
display: flex; display: flex;
gap: 2px; gap: 6px;
} }
.control-btn { .control-btn {
display: flex; display: flex;
@ -238,9 +238,6 @@ html, body, #root {
color: var(--app-primary); color: var(--app-primary);
font-weight: 600; font-weight: 600;
} }
.control-group .control-btn:not(:first-child) { border-left: none; border-radius: 0 6px 6px 0; }
.control-group .control-btn:first-child:not(:last-child) { border-radius: 6px 0 0 6px; }
/* ── Filter bars ─────────────────────────────────────────────── */ /* ── Filter bars ─────────────────────────────────────────────── */
.filter-section { .filter-section {
background: var(--card-bg); background: var(--card-bg);
@ -347,38 +344,16 @@ html, body, #root {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
background: #e5e7eb; background: #e5e7eb;
border: 2px solid #d1d5db; border-top: 2px solid #d1d5db;
border-radius: 8px; border-bottom: 2px solid #d1d5db;
margin: 8px 23px 5px; margin: 8px 0 5px;
padding: 6px 12px; padding: 6px 37px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
} }
.category-header::before,
.category-header::after {
content: '';
position: absolute;
top: -2px;
bottom: -2px;
width: 16px;
background: #e5e7eb;
border-top: 2px solid #d1d5db;
border-bottom: 2px solid #d1d5db;
pointer-events: none;
}
.category-header::before {
left: -16px;
border-right: 2px solid #d1d5db;
border-radius: 0 6px 6px 0;
}
.category-header::after {
right: -16px;
border-left: 2px solid #d1d5db;
border-radius: 6px 0 0 6px;
}
.category-name { .category-name {
flex: 1; flex: 1;
font-size: 12px; font-size: 12px;

View file

@ -1,11 +1,11 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { RefreshCw, Settings, List, LayoutList, Filter, BarChart2, RotateCcw } from 'lucide-react' import { RefreshCw, Settings, List, LayoutList, Filter, BarChart2, RotateCcw, Tag } from 'lucide-react'
import type { RoomData, AppConfig, Category, FilterState, StatFilters, SemanticFlowKey, ActivityEntry } from '../types' import type { RoomData, AppConfig, Category, FilterState, StatFilters, SemanticFlowKey, ActivityEntry } from '../types'
import { SEMANTIC_FLOW_MAP } from '../types' import { SEMANTIC_FLOW_MAP } from '../types'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
import CategoryGroup from '../components/CategoryGroup' import CategoryGroup from '../components/CategoryGroup'
import RoomCard from '../components/RoomCard' import RoomCard from '../components/RoomCard'
import { FilterBar, StatFilterBar } from '../components/FilterBar' import { CategoryFilterBar, StatesFilterBar, StatFilterBar } from '../components/FilterBar'
import ActivityPanel from '../components/ActivityPanel' import ActivityPanel from '../components/ActivityPanel'
import RoomModal from '../components/RoomModal' import RoomModal from '../components/RoomModal'
import CheckoutNotification, { useToasts } from '../components/CheckoutNotification' import CheckoutNotification, { useToasts } from '../components/CheckoutNotification'
@ -49,6 +49,9 @@ export default function Planner() {
const [viewMode, setViewMode] = useState<'flat' | 'grouped'>(() => const [viewMode, setViewMode] = useState<'flat' | 'grouped'>(() =>
(localStorage.getItem('rp-view-mode') ?? 'grouped') as 'flat' | 'grouped' (localStorage.getItem('rp-view-mode') ?? 'grouped') as 'flat' | 'grouped'
) )
const [categoriesFilterVisible, setCategoriesFilterVisible] = useState(() =>
JSON.parse(localStorage.getItem('rp-categories-visible') ?? 'true')
)
const [statesFilterVisible, setStatesFilterVisible] = useState(() => const [statesFilterVisible, setStatesFilterVisible] = useState(() =>
JSON.parse(localStorage.getItem('rp-states-visible') ?? 'true') JSON.parse(localStorage.getItem('rp-states-visible') ?? 'true')
) )
@ -57,6 +60,7 @@ export default function Planner() {
) )
useEffect(() => { localStorage.setItem('rp-controls-visible', JSON.stringify(controlsVisible)) }, [controlsVisible]) useEffect(() => { localStorage.setItem('rp-controls-visible', JSON.stringify(controlsVisible)) }, [controlsVisible])
useEffect(() => { localStorage.setItem('rp-view-mode', viewMode) }, [viewMode]) useEffect(() => { localStorage.setItem('rp-view-mode', viewMode) }, [viewMode])
useEffect(() => { localStorage.setItem('rp-categories-visible', JSON.stringify(categoriesFilterVisible)) }, [categoriesFilterVisible])
useEffect(() => { localStorage.setItem('rp-states-visible', JSON.stringify(statesFilterVisible)) }, [statesFilterVisible]) useEffect(() => { localStorage.setItem('rp-states-visible', JSON.stringify(statesFilterVisible)) }, [statesFilterVisible])
useEffect(() => { localStorage.setItem('rp-stats-visible', JSON.stringify(statsFilterVisible)) }, [statsFilterVisible]) useEffect(() => { localStorage.setItem('rp-stats-visible', JSON.stringify(statsFilterVisible)) }, [statsFilterVisible])
@ -178,6 +182,7 @@ export default function Planner() {
setFilters({ categories: {}, flowTypes: {} }) setFilters({ categories: {}, flowTypes: {} })
setStatFilters({ newbookTasks: 'off', cleanDirty: 'off' }) setStatFilters({ newbookTasks: 'off', cleanDirty: 'off' })
setViewMode('grouped') setViewMode('grouped')
setCategoriesFilterVisible(true)
setStatesFilterVisible(true) setStatesFilterVisible(true)
setStatsFilterVisible(true) setStatsFilterVisible(true)
}, []) }, [])
@ -312,6 +317,9 @@ export default function Planner() {
</button> </button>
</div> </div>
<div className="control-group"> <div className="control-group">
<button className={`control-btn${categoriesFilterVisible ? ' active' : ''}`} onClick={() => setCategoriesFilterVisible((v: boolean) => !v)}>
<Tag size={13} strokeWidth={1.75} /> Categories Filter
</button>
<button className={`control-btn${statesFilterVisible ? ' active' : ''}`} onClick={() => setStatesFilterVisible((v: boolean) => !v)}> <button className={`control-btn${statesFilterVisible ? ' active' : ''}`} onClick={() => setStatesFilterVisible((v: boolean) => !v)}>
<Filter size={13} strokeWidth={1.75} /> States Filter <Filter size={13} strokeWidth={1.75} /> States Filter
</button> </button>
@ -328,16 +336,17 @@ export default function Planner() {
)} )}
{/* Filters */} {/* Filters */}
{statesFilterVisible && ( {categoriesFilterVisible && (
<FilterBar <CategoryFilterBar
filters={filters} filters={filters}
categories={orderedCategories} categories={orderedCategories}
rooms={rooms} rooms={rooms}
viewDate={viewDate}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
onToggleFlow={toggleFlow}
/> />
)} )}
{statesFilterVisible && (
<StatesFilterBar filters={filters} rooms={rooms} onToggleFlow={toggleFlow} />
)}
{statsFilterVisible && <StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />} {statsFilterVisible && <StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />}
{/* Main area */} {/* Main area */}