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.
- tbr_deal_finder/__init__.py +1 -5
- tbr_deal_finder/__main__.py +7 -0
- tbr_deal_finder/book.py +16 -8
- tbr_deal_finder/cli.py +13 -27
- tbr_deal_finder/config.py +2 -2
- tbr_deal_finder/desktop_updater.py +147 -0
- tbr_deal_finder/gui/__init__.py +0 -0
- tbr_deal_finder/gui/main.py +725 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +93 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +291 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +370 -0
- tbr_deal_finder/gui/pages/settings.py +389 -0
- tbr_deal_finder/retailer/amazon.py +58 -7
- tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
- tbr_deal_finder/retailer/audible.py +2 -1
- tbr_deal_finder/retailer/chirp.py +55 -11
- tbr_deal_finder/retailer/kindle.py +31 -19
- tbr_deal_finder/retailer/librofm.py +53 -20
- tbr_deal_finder/retailer/models.py +31 -1
- tbr_deal_finder/retailer_deal.py +38 -14
- tbr_deal_finder/tracked_books.py +24 -18
- tbr_deal_finder/utils.py +64 -2
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +18 -87
- tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
- tbr_deal_finder-0.2.1.dist-info/RECORD +0 -25
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
- {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()
|