tbr-deal-finder 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +16 -8
  4. tbr_deal_finder/cli.py +13 -27
  5. tbr_deal_finder/config.py +2 -2
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +725 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +93 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +291 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +370 -0
  15. tbr_deal_finder/gui/pages/settings.py +389 -0
  16. tbr_deal_finder/retailer/amazon.py +58 -7
  17. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  18. tbr_deal_finder/retailer/audible.py +2 -1
  19. tbr_deal_finder/retailer/chirp.py +55 -11
  20. tbr_deal_finder/retailer/kindle.py +31 -19
  21. tbr_deal_finder/retailer/librofm.py +53 -20
  22. tbr_deal_finder/retailer/models.py +31 -1
  23. tbr_deal_finder/retailer_deal.py +38 -14
  24. tbr_deal_finder/tracked_books.py +24 -18
  25. tbr_deal_finder/utils.py +64 -2
  26. tbr_deal_finder/version_check.py +40 -0
  27. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +18 -87
  28. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  29. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  30. tbr_deal_finder-0.2.1.dist-info/RECORD +0 -25
  31. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  32. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,604 @@
1
+ import logging
2
+ from collections import Counter
3
+
4
+ import flet as ft
5
+ from datetime import datetime, timedelta
6
+ from typing import List
7
+
8
+ from tbr_deal_finder.book import Book, BookFormat
9
+ from tbr_deal_finder.utils import get_duckdb_conn, execute_query, float_to_currency
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def build_book_price_section(historical_data: list[dict]) -> ft.Column:
15
+ retailer_data = dict()
16
+ available_colors = [
17
+ ft.Colors.AMBER,
18
+ ft.Colors.INDIGO,
19
+ ft.Colors.CYAN,
20
+ ft.Colors.ORANGE,
21
+ ft.Colors.RED,
22
+ ft.Colors.GREEN,
23
+ ft.Colors.YELLOW,
24
+ ft.Colors.BLUE,
25
+ ]
26
+
27
+ min_price = None
28
+ max_price = None
29
+ min_time = None
30
+ max_dt = datetime.now()
31
+ max_time = max_dt.timestamp()
32
+
33
+ for record in historical_data:
34
+ if record["retailer"] not in retailer_data:
35
+ retailer_data[record["retailer"]] = dict()
36
+ retailer_data[record["retailer"]]["color"] = available_colors.pop(0)
37
+ retailer_data[record["retailer"]]["data"] = []
38
+ retailer_data[record["retailer"]]["last_update"] = None
39
+
40
+ # Convert datetime to timestamp for x-axis
41
+ timestamp = record["timepoint"].timestamp()
42
+ tooltip = f"{record['retailer']}: {float_to_currency(record['current_price'])}"
43
+
44
+ if last_update := retailer_data[record["retailer"]]["last_update"]:
45
+ max_update_marker = last_update["timepoint"] + timedelta(days=1)
46
+ last_price = last_update["current_price"]
47
+ pad_tooltip = f"{record['retailer']}: {float_to_currency(last_price)}"
48
+ # Padding to show more consistent info on graph hover
49
+ while record["timepoint"] > max_update_marker:
50
+ retailer_data[record["retailer"]]["data"].append(
51
+ ft.LineChartDataPoint(max_update_marker.timestamp(), last_price, tooltip=pad_tooltip)
52
+ )
53
+ max_update_marker = max_update_marker + timedelta(days=1)
54
+
55
+ retailer_data[record["retailer"]]["last_update"] = record
56
+ retailer_data[record["retailer"]]["data"].append(
57
+ ft.LineChartDataPoint(timestamp, record["current_price"], tooltip=tooltip)
58
+ )
59
+
60
+ # Track price range
61
+ if not min_price or record["current_price"] < min_price:
62
+ min_price = record["current_price"]
63
+ if not max_price or record["list_price"] > max_price:
64
+ max_price = record["list_price"]
65
+
66
+ # Track time range
67
+ if not min_time or timestamp < min_time:
68
+ min_time = timestamp
69
+
70
+ # Add hover padding to current date
71
+ for retailer, data in retailer_data.items():
72
+ last_update = data["last_update"]
73
+ max_update_marker = last_update["timepoint"] + timedelta(days=1)
74
+ last_price = last_update["current_price"]
75
+ pad_tooltip = f"{retailer}: {float_to_currency(last_price)}"
76
+ # Padding to show more consistent info on graph hover
77
+ while max_dt > (max_update_marker + timedelta(hours=6)):
78
+ max_update_marker_ts = max_update_marker.timestamp()
79
+ data["data"].append(
80
+ ft.LineChartDataPoint(max_update_marker_ts, last_price, tooltip=pad_tooltip)
81
+ )
82
+ data["last_update"]["timepoint"] = max_update_marker
83
+
84
+ max_update_marker = max_update_marker + timedelta(days=1)
85
+
86
+ # Add data point if one doesn't exist for max time so lines don't just end abruptly
87
+ for retailer, data in retailer_data.items():
88
+ last_update = data["last_update"]
89
+ last_entry = last_update["timepoint"].timestamp()
90
+ if last_entry == max_time:
91
+ continue
92
+
93
+ last_price = last_update["current_price"]
94
+ pad_tooltip = f"{retailer}: {float_to_currency(last_price)}"
95
+ data["data"].append(
96
+ ft.LineChartDataPoint(max_time, last_price, tooltip=pad_tooltip)
97
+ )
98
+
99
+ # Y-axis setup
100
+ y_min = min_price // 5 * 5 # Keep as float
101
+ y_max = ((max_price + 4) // 5) * 5 # Round up to nearest 5
102
+ y_axis_labels = []
103
+ for val in range(int(y_min), int(y_max) + 1, 5):
104
+ y_axis_labels.append(
105
+ ft.ChartAxisLabel(
106
+ value=val,
107
+ label=ft.Text(float_to_currency(val), no_wrap=True)
108
+ )
109
+ )
110
+
111
+ # X-axis setup - create labels for actual data points
112
+ x_axis_labels = []
113
+
114
+ # Get unique months from the data
115
+ unique_months = set()
116
+ for record in historical_data:
117
+ timepoint = record["timepoint"].replace(day=1, hour=0, minute=0, second=0, microsecond=0)
118
+ month_year = timepoint.strftime('%B %Y')
119
+ unique_months.add((timepoint.timestamp(), month_year))
120
+
121
+ # Sort by timestamp and create labels
122
+ for timestamp, month_year in sorted(unique_months):
123
+ date_str = month_year.split()[0] # Just show month abbreviation
124
+ x_axis_labels.append(
125
+ ft.ChartAxisLabel(
126
+ value=timestamp,
127
+ label=ft.Container(
128
+ content=ft.Text(date_str),
129
+ padding=ft.padding.only(left=20) # Add top padding
130
+ )
131
+ )
132
+ )
133
+
134
+ # Create the chart
135
+ chart = ft.LineChart(
136
+ data_series=[
137
+ ft.LineChartData(
138
+ data_points=retailer["data"],
139
+ stroke_width=3,
140
+ color=retailer["color"],
141
+ curved=True,
142
+ stroke_cap_round=True,
143
+ )
144
+ for retailer in retailer_data.values()
145
+ ],
146
+ border=ft.border.all(1, ft.Colors.with_opacity(0.2, ft.Colors.ON_SURFACE)),
147
+ horizontal_grid_lines=ft.ChartGridLines(
148
+ interval=5,
149
+ color=ft.Colors.with_opacity(0.2, ft.Colors.ON_SURFACE),
150
+ width=1,
151
+ ),
152
+ vertical_grid_lines=ft.ChartGridLines(
153
+ interval=604800, # 1 week
154
+ color=ft.Colors.with_opacity(0.2, ft.Colors.ON_SURFACE),
155
+ width=1,
156
+ ),
157
+ left_axis=ft.ChartAxis(labels=y_axis_labels, labels_size=50),
158
+ bottom_axis=ft.ChartAxis(labels=x_axis_labels, labels_interval=3600), # 1 hour
159
+ expand=False,
160
+ height=200,
161
+ width=850,
162
+ min_x=min_time,
163
+ max_x=max_time,
164
+ min_y=y_min,
165
+ max_y=y_max,
166
+ interactive=True,
167
+ )
168
+
169
+ # Legend
170
+ row_data = []
171
+ for retailer_name, retailer in retailer_data.items():
172
+ row_data.append(
173
+ ft.Row([
174
+ ft.Container(width=20, height=3, bgcolor=retailer["color"]),
175
+ ft.Text(retailer_name),
176
+ ], spacing=5),
177
+ )
178
+ legend = ft.Row(row_data, spacing=20)
179
+
180
+ return ft.Column(
181
+ [
182
+ ft.Container(
183
+ content=chart,
184
+ padding=25,
185
+ ),
186
+ ft.Container(
187
+ content=legend,
188
+ alignment=ft.alignment.center,
189
+ ),
190
+ ],
191
+ spacing=0
192
+ )
193
+
194
+
195
+ class BookDetailsPage:
196
+ def __init__(self, app):
197
+ self.app = app
198
+ self.book = None
199
+ self.selected_format = None # Will be set when book is selected
200
+ self.current_deals = []
201
+ self.historical_data = []
202
+
203
+ def build(self):
204
+ """Build the book details page content"""
205
+ if not self.app.selected_book:
206
+ return ft.Text("No book selected")
207
+
208
+ self.book = self.app.selected_book
209
+
210
+ # Set default format if not already set
211
+ if self.selected_format is None:
212
+ self.selected_format = self.get_default_format()
213
+
214
+ self.load_book_data()
215
+
216
+ # Header with back button and book info
217
+ header = self.build_header()
218
+
219
+ # Format selector (always show prominently)
220
+ format_selector = self.build_format_selector()
221
+
222
+ # Current pricing section
223
+ current_pricing = self.build_current_pricing()
224
+
225
+ # Historical pricing chart
226
+ historical_chart = self.build_historical_chart()
227
+
228
+ # Book details section
229
+ book_info = self.build_book_info()
230
+
231
+ return ft.Column([
232
+ header,
233
+ format_selector,
234
+ ft.Divider(),
235
+ book_info,
236
+ ft.Divider(),
237
+ current_pricing,
238
+ ft.Divider(),
239
+ historical_chart,
240
+ ], spacing=20, scroll=ft.ScrollMode.AUTO)
241
+
242
+ def build_header(self):
243
+ """Build the header with book title"""
244
+
245
+ title = self.book.title
246
+ if len(title) > 80:
247
+ title = f"{title[:80]}..."
248
+
249
+ return ft.Row([
250
+ ft.Column([
251
+ ft.Text(title, size=24, weight=ft.FontWeight.BOLD),
252
+ ft.Text(f"by {self.book.authors}", size=16, color=ft.Colors.GREY_600)
253
+ ], spacing=5, expand=True)
254
+ ], alignment=ft.MainAxisAlignment.START)
255
+
256
+ def get_default_format(self) -> BookFormat:
257
+ """Get the default format for this book, preferring audiobook"""
258
+ # Check what formats are available for this book
259
+ available_formats = self.get_available_formats()
260
+
261
+ # Prefer audiobook if available, otherwise use ebook
262
+ if BookFormat.AUDIOBOOK in available_formats:
263
+ return BookFormat.AUDIOBOOK
264
+ elif BookFormat.EBOOK in available_formats:
265
+ return BookFormat.EBOOK
266
+ else:
267
+ # Fallback to the book's original format
268
+ return self.book.format
269
+
270
+ def get_available_formats(self) -> List[BookFormat]:
271
+ """Get list of formats available for this book"""
272
+ db_conn = get_duckdb_conn()
273
+
274
+ query = """
275
+ SELECT DISTINCT format
276
+ FROM retailer_deal
277
+ WHERE title = ? AND authors = ? AND deleted IS NOT TRUE
278
+ """
279
+
280
+ try:
281
+ results = execute_query(db_conn, query, [self.book.title, self.book.authors])
282
+ formats = []
283
+ for row in results:
284
+ try:
285
+ formats.append(BookFormat(row['format']))
286
+ except ValueError:
287
+ continue # Skip invalid format values
288
+ return formats
289
+ except Exception as e:
290
+ logger.info(f"Error getting available formats: {e}")
291
+ return [self.book.format] # Fallback to original format
292
+
293
+ def build_format_selector(self):
294
+ """Build format selector with text display and dropdown"""
295
+ available_formats = self.get_available_formats()
296
+ logger.info(f"Available formats for {self.book.title}: {[f.value for f in available_formats]}")
297
+ logger.info(f"Currently selected format: {self.selected_format.value if self.selected_format else 'None'}")
298
+
299
+ format_text_str = "Format: "
300
+ if len(available_formats) <= 1:
301
+ format_text_str = f"{format_text_str}{self.selected_format.value}"
302
+
303
+ # Current format display text
304
+ format_text = ft.Text(
305
+ format_text_str,
306
+ size=18,
307
+ weight=ft.FontWeight.BOLD
308
+ )
309
+
310
+ if len(available_formats) <= 1:
311
+ # Only one format available, just show the text
312
+ return ft.Container(
313
+ content=format_text,
314
+ padding=ft.padding.symmetric(0, 10)
315
+ )
316
+
317
+ # Multiple formats available, show text + dropdown
318
+ format_options = []
319
+ for format_type in available_formats:
320
+ format_options.append(
321
+ ft.dropdown.Option(
322
+ key=format_type.value,
323
+ text=format_type.value
324
+ )
325
+ )
326
+
327
+ format_dropdown = ft.Dropdown(
328
+ value=self.selected_format.value,
329
+ options=format_options,
330
+ on_change=self.on_format_changed,
331
+ width=200,
332
+ menu_height=80,
333
+ max_menu_height=80
334
+ )
335
+
336
+ return ft.Container(
337
+ content=ft.Row([
338
+ format_text,
339
+ format_dropdown
340
+ ], spacing=20, alignment=ft.MainAxisAlignment.START),
341
+ padding=ft.padding.symmetric(10, 10)
342
+ )
343
+
344
+ def create_format_badge(self, format_type: BookFormat):
345
+ """Create a format badge"""
346
+ color = ft.Colors.BLUE if format_type == BookFormat.EBOOK else ft.Colors.GREEN
347
+ return ft.Container(
348
+ content=ft.Text(
349
+ format_type.value,
350
+ size=12,
351
+ color=ft.Colors.WHITE,
352
+ weight=ft.FontWeight.BOLD
353
+ ),
354
+ bgcolor=color,
355
+ border_radius=12,
356
+ padding=ft.padding.symmetric(12, 6),
357
+ alignment=ft.alignment.center
358
+ )
359
+
360
+ def on_format_changed(self, e):
361
+ """Handle format selection change"""
362
+ new_format = BookFormat(e.control.value)
363
+ logger.info(f"Format changed to: {new_format.value}")
364
+ if new_format != self.selected_format:
365
+ self.selected_format = new_format
366
+ self.refresh_format_data()
367
+
368
+ def refresh_format_data(self):
369
+ """Refresh data for the new format without rebuilding entire page"""
370
+ logger.info(f"Refreshing data for format: {self.selected_format.value}")
371
+ # Reload data for the new format
372
+ self.load_book_data()
373
+ # Rebuild the page content
374
+ self.app.update_content()
375
+
376
+ def set_initial_format(self, format_type: BookFormat):
377
+ """Set the initial format to display"""
378
+ self.selected_format = format_type
379
+
380
+ def build_current_pricing(self):
381
+ """Build current pricing information section"""
382
+ if not self.current_deals:
383
+ return ft.Container(
384
+ content=ft.Column([
385
+ ft.Text("Current Pricing", size=20, weight=ft.FontWeight.BOLD),
386
+ ft.Text("No current deals available for this book", color=ft.Colors.GREY_600)
387
+ ]),
388
+ padding=20,
389
+ border=ft.border.all(1, ft.Colors.OUTLINE),
390
+ border_radius=8
391
+ )
392
+
393
+ # Group deals by retailer
394
+ retailer_cards = []
395
+ for deal in self.current_deals:
396
+ card = self.create_retailer_card(deal)
397
+ retailer_cards.append(card)
398
+
399
+ return ft.Container(
400
+ content=ft.Column([
401
+ ft.Text("Current Pricing", size=20, weight=ft.FontWeight.BOLD),
402
+ ft.Text(f"Showing prices for {len(retailer_cards)} retailer(s)", color=ft.Colors.GREY_600),
403
+ ft.Row(retailer_cards, wrap=True, spacing=10)
404
+ ], spacing=15),
405
+ padding=20,
406
+ border=ft.border.all(1, ft.Colors.OUTLINE),
407
+ border_radius=8
408
+ )
409
+
410
+ def create_retailer_card(self, deal: Book):
411
+ """Create a card for a retailer's pricing"""
412
+ # Calculate discount color
413
+ discount = deal.discount()
414
+ if discount >= 50:
415
+ discount_color = ft.Colors.GREEN
416
+ elif discount >= 30:
417
+ discount_color = ft.Colors.ORANGE
418
+ else:
419
+ discount_color = ft.Colors.RED
420
+
421
+ return ft.Card(
422
+ content=ft.Container(
423
+ content=ft.Column([
424
+ ft.Text(deal.retailer, weight=ft.FontWeight.BOLD, size=16),
425
+ ft.Text(
426
+ deal.current_price_string(),
427
+ size=20,
428
+ weight=ft.FontWeight.BOLD,
429
+ color=ft.Colors.GREEN
430
+ ),
431
+ ft.Text(f"was {deal.list_price_string()}", color=ft.Colors.GREY_500),
432
+ ft.Container(
433
+ content=ft.Text(
434
+ f"{discount}% OFF",
435
+ color=ft.Colors.WHITE,
436
+ weight=ft.FontWeight.BOLD,
437
+ size=12
438
+ ),
439
+ bgcolor=discount_color,
440
+ border_radius=8,
441
+ padding=ft.padding.symmetric(8, 4),
442
+ alignment=ft.alignment.center
443
+ )
444
+ ], spacing=5, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
445
+ padding=15,
446
+ width=150
447
+ )
448
+ )
449
+
450
+ def build_historical_chart(self):
451
+ """Build historical pricing chart"""
452
+ if not self.has_historical_data():
453
+ return ft.Container(
454
+ content=ft.Column([
455
+ ft.Text("Historical Pricing", size=20, weight=ft.FontWeight.BOLD),
456
+ ft.Text("No historical data available", color=ft.Colors.GREY_600)
457
+ ]),
458
+ padding=20,
459
+ border=ft.border.all(1, ft.Colors.OUTLINE),
460
+ border_radius=8
461
+ )
462
+
463
+ # Create the chart
464
+ chart_fig = build_book_price_section(self.historical_data)
465
+
466
+ return ft.Container(
467
+ content=ft.Column([
468
+ ft.Text("Historical Pricing", size=20, weight=ft.FontWeight.BOLD),
469
+ ft.Text("Price trends over the last 3 months", color=ft.Colors.GREY_600),
470
+ ft.Container(
471
+ content=chart_fig,
472
+ height=300,
473
+ alignment=ft.alignment.center
474
+ )
475
+ # Note: Flet has limited Plotly integration. In a real implementation,
476
+ # you might use ft.PlotlyChart or save as image and display
477
+ ], spacing=15),
478
+ padding=20,
479
+ border=ft.border.all(1, ft.Colors.OUTLINE),
480
+ border_radius=8
481
+ )
482
+
483
+ def build_book_info(self):
484
+ """Build book information section"""
485
+ info_items = []
486
+
487
+ # Basic info
488
+ info_items.extend([
489
+ self.create_info_row("Title", self.book.title),
490
+ self.create_info_row("Author(s)", self.book.authors),
491
+ self.create_info_row("Format", self.selected_format.value)
492
+ ])
493
+
494
+ # Price statistics from current deals
495
+ if self.current_deals:
496
+ prices = [deal.current_price for deal in self.current_deals]
497
+ discounts = [deal.discount() for deal in self.current_deals]
498
+
499
+
500
+ if len(prices) > 1:
501
+ info_items.append(self.create_info_row("Lowest Price", f"${min(prices):.2f}"))
502
+ else:
503
+ info_items.append(self.create_info_row("Current Price", f"${min(prices):.2f}"))
504
+
505
+ if self.has_historical_data():
506
+ historical_prices = [retailer["current_price"] for retailer in self.historical_data]
507
+ lowest_ever_price = min(historical_prices)
508
+ info_items.append(self.create_info_row("Lowest Ever", f"${lowest_ever_price:.2f}"))
509
+
510
+ if len(prices) > 1:
511
+ info_items.extend([
512
+ self.create_info_row("Highest Price", f"${max(prices):.2f}"),
513
+ self.create_info_row("Best Discount", f"{max(discounts)}%"),
514
+ ])
515
+
516
+ info_items.append(
517
+ self.create_info_row("Available At", f"{len(self.current_deals)} retailer(s)")
518
+ )
519
+
520
+ return ft.Container(
521
+ content=ft.Column([
522
+ ft.Text("Book Information", size=20, weight=ft.FontWeight.BOLD),
523
+ ft.Column(info_items, spacing=8)
524
+ ], spacing=15),
525
+ padding=20,
526
+ border=ft.border.all(1, ft.Colors.OUTLINE),
527
+ border_radius=8
528
+ )
529
+
530
+ def create_info_row(self, label: str, value: str):
531
+ """Create an information row"""
532
+ return ft.Row([
533
+ ft.Text(f"{label}:", weight=ft.FontWeight.BOLD, width=150),
534
+ ft.Text(value, expand=True)
535
+ ])
536
+
537
+ def load_book_data(self):
538
+ """Load current deals and historical data for the book"""
539
+ try:
540
+ self.load_current_deals()
541
+ self.load_historical_data()
542
+ except Exception as e:
543
+ logger.info(f"Error loading book data: {e}")
544
+ self.current_deals = []
545
+ self.historical_data = []
546
+
547
+ def load_current_deals(self):
548
+ """Load current active deals for this book in the selected format"""
549
+ db_conn = get_duckdb_conn()
550
+
551
+ # Get current deals for this specific book and format
552
+ query = """
553
+ SELECT * exclude(deal_id)
554
+ FROM retailer_deal
555
+ WHERE title = ? AND authors = ? AND format = ?
556
+ QUALIFY ROW_NUMBER() OVER (PARTITION BY retailer ORDER BY timepoint DESC) = 1
557
+ AND deleted IS NOT TRUE
558
+ ORDER BY current_price ASC
559
+ """
560
+
561
+ results = execute_query(
562
+ db_conn,
563
+ query,
564
+ [self.book.title, self.book.authors, self.selected_format.value]
565
+ )
566
+
567
+ self.current_deals = [Book(**deal) for deal in results]
568
+
569
+ def load_historical_data(self):
570
+ """Load historical pricing data for this book in the selected format"""
571
+ db_conn = get_duckdb_conn()
572
+
573
+ # Get historical data for the last 90 days
574
+ cutoff_date = datetime.now() - timedelta(days=90)
575
+
576
+ query = """
577
+ SELECT retailer, list_price, current_price, timepoint
578
+ FROM retailer_deal
579
+ WHERE title = ? AND authors = ? AND format = ?
580
+ AND timepoint >= ?
581
+ ORDER BY timepoint ASC
582
+ """
583
+
584
+ results = execute_query(
585
+ db_conn,
586
+ query,
587
+ [self.book.title, self.book.authors, self.selected_format.value, cutoff_date]
588
+ )
589
+
590
+ self.historical_data = results
591
+
592
+ def has_historical_data(self) -> bool:
593
+ """Returns True if at least one retailer has more than 1 record in retailer_deal"""
594
+ if not self.historical_data:
595
+ return False
596
+
597
+ retailer_refs = [deal["retailer"] for deal in self.historical_data]
598
+ retailer_counts = Counter(retailer_refs)
599
+ return any(rc > 1 for rc in retailer_counts.values())
600
+
601
+ def refresh_data(self):
602
+ """Refresh book data"""
603
+ self.load_book_data()
604
+ self.app.update_content()