Skip to content

Commit 9fda8b0

Browse files
wesmclaude
andcommitted
perf: Use Polars Series for merchant filtering in edit modal
**Performance Optimization:** Store all_merchants as Polars Series instead of Python list to enable fast vectorized filtering operations. **Before (slow with thousands of merchants):** - Store as Python list - Filter: `[m for m in merchants if query in m.lower()]` - Python loop - Sort/dedupe: `sorted(set(matches))[:20]` - Python operations **After (fast with thousands of merchants):** - Store as Polars Series in __init__ - Filter: `series.filter(series.str.to_lowercase().str.contains(query))` - vectorized - Sort/dedupe/limit: `filtered.unique().sort().head(20)` - Polars operations - Only convert to Python list at the end for UI rendering **Implementation Details:** - Added `import polars as pl` at top of file (no inline imports) - Type annotation: `self.all_merchants: pl.Series | None` - Variable name unchanged (self.all_merchants) - type annotation makes it clear - Updated all None checks to use `is not None` (not truthiness) - Single conversion from list to Series in __init__ (not on every keystroke) **Performance Benefits:** - Vectorized string operations (much faster than Python loops) - No repeated Series creation (convert once in __init__) - Polars handles deduplication and sorting natively - Especially noticeable with 1000+ unique merchants **Testing:** - All 765 tests pass - test_editing.py: 100% coverage - No behavior changes - purely performance optimization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e931a8b commit 9fda8b0

File tree

1 file changed

+27
-18
lines changed

1 file changed

+27
-18
lines changed

moneyflow/screens/edit_screens.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from textual.widgets import Button, Input, Label, OptionList, Static
2020
from textual.widgets.option_list import Option
2121

22+
import polars as pl
23+
2224

2325
class EditMerchantScreen(ModalScreen):
2426
"""
@@ -104,7 +106,10 @@ def __init__(
104106
super().__init__()
105107
self.current_merchant = current_merchant
106108
self.transaction_count = transaction_count
107-
self.all_merchants = all_merchants or []
109+
# Store merchants as Polars Series for fast vectorized filtering
110+
self.all_merchants: pl.Series | None = (
111+
pl.Series("merchant", all_merchants) if all_merchants else None
112+
)
108113
self.transaction_details = transaction_details
109114

110115
def compose(self) -> ComposeResult:
@@ -147,7 +152,7 @@ def compose(self) -> ComposeResult:
147152
classes="edit-input",
148153
)
149154

150-
if self.all_merchants:
155+
if self.all_merchants is not None:
151156
yield Static("Existing merchants - ↑/↓=Navigate | Enter=Select", id="suggestions-count")
152157
yield OptionList(id="suggestions")
153158

@@ -157,7 +162,7 @@ def compose(self) -> ComposeResult:
157162

158163
async def on_mount(self) -> None:
159164
"""Initialize suggestions list."""
160-
if self.all_merchants:
165+
if self.all_merchants is not None:
161166
await self._update_suggestions("")
162167
self.query_one("#merchant-input", Input).focus()
163168

@@ -168,24 +173,28 @@ async def _update_suggestions(self, query: str) -> None:
168173
merchant_input = self.query_one("#merchant-input", Input)
169174
user_input = merchant_input.value.strip()
170175

171-
# Filter merchants (include current merchant in results)
176+
# Filter merchants using Polars for performance with large merchant lists
172177
if query:
173-
matches = [m for m in self.all_merchants if m and query in m.lower()]
178+
# Filter using Polars str.contains (much faster than Python loop for thousands of merchants)
179+
filtered = self.all_merchants.filter(
180+
self.all_merchants.str.to_lowercase().str.contains(query.lower())
181+
)
174182
else:
175-
matches = list(self.all_merchants)
183+
filtered = self.all_merchants
184+
185+
# Deduplicate, sort, and limit using Polars operations (faster than Python)
186+
top_matches = filtered.unique().sort().head(20)
187+
matches_list = top_matches.to_list()
176188

177189
# Update count
178-
count_widget.update(f"{len(matches)} matching merchants - ↑/↓=Navigate | Enter=Select")
190+
count_widget.update(f"{len(filtered)} matching merchants - ↑/↓=Navigate | Enter=Select")
179191

180192
# Clear and rebuild
181193
option_list.clear_options()
182194

183-
# Add matches (sorted)
184-
sorted_matches = sorted(set(matches))[:20]
185-
186195
# Add first match (if any)
187-
if sorted_matches:
188-
option_list.add_option(Option(sorted_matches[0], id=sorted_matches[0]))
196+
if len(matches_list) > 0:
197+
option_list.add_option(Option(matches_list[0], id=matches_list[0]))
189198

190199
# Always add user's input as "create new" option as SECOND option
191200
# (if not empty and different from current)
@@ -194,8 +203,8 @@ async def _update_suggestions(self, query: str) -> None:
194203
option_list.add_option(Option(f'"{user_input}"', id=f"__new__:{user_input}"))
195204

196205
# Add remaining matches (positions 3+)
197-
if len(sorted_matches) > 1:
198-
for merchant in sorted_matches[1:]:
206+
if len(matches_list) > 1:
207+
for merchant in matches_list[1:]:
199208
option_list.add_option(Option(merchant, id=merchant))
200209

201210
# Highlight first item by default so Enter works immediately
@@ -204,7 +213,7 @@ async def _update_suggestions(self, query: str) -> None:
204213

205214
async def on_input_changed(self, event: Input.Changed) -> None:
206215
"""Filter merchant suggestions as user types."""
207-
if event.input.id != "merchant-input" or not self.all_merchants:
216+
if event.input.id != "merchant-input" or self.all_merchants is None:
208217
return
209218

210219
query = event.value.lower().strip()
@@ -249,7 +258,7 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
249258
# When Enter is pressed in the input field (without using arrow keys to navigate),
250259
# always auto-select the first existing match if there are any matches.
251260
# To use the "create new" option, user must explicitly arrow down to it.
252-
if self.all_merchants:
261+
if self.all_merchants is not None:
253262
option_list = self.query_one("#suggestions", OptionList)
254263

255264
# Find first non-"create new" option (first existing match)
@@ -284,14 +293,14 @@ def on_key(self, event: Key) -> None:
284293
self.dismiss(None)
285294
elif event.key == "down":
286295
# Move focus from input to suggestions (if list has items)
287-
if self.all_merchants:
296+
if self.all_merchants is not None:
288297
option_list = self.query_one("#suggestions", OptionList)
289298
if not option_list.has_focus and option_list.option_count > 0:
290299
event.stop() # Stop only when moving TO the list
291300
option_list.focus()
292301
elif event.key == "up":
293302
# Move focus from list back to input (if at top of list)
294-
if self.all_merchants:
303+
if self.all_merchants is not None:
295304
option_list = self.query_one("#suggestions", OptionList)
296305
merchant_input = self.query_one("#merchant-input", Input)
297306
if option_list.has_focus and option_list.highlighted == 0:

0 commit comments

Comments
 (0)