Sadeep Sachintha commited on
Commit
61207aa
·
1 Parent(s): f57f933

feat: implement async database session management and CBSL currency exchange rate service with persistent caching

Browse files
Files changed (10) hide show
  1. README.md +43 -2
  2. bot/handlers.py +124 -0
  3. db/models.py +10 -1
  4. db/session.py +41 -0
  5. main.py +36 -1
  6. rate_cache.json +20 -20
  7. services/fx_service.py +41 -0
  8. static/app.js +145 -2
  9. static/index.html +96 -0
  10. static/style.css +64 -0
README.md CHANGED
@@ -14,12 +14,14 @@ FlyRates is an ultra-clean, high-availability Telegram bot designed for single-p
14
 
15
  ## Features ✨
16
  - **Premium Glassmorphic Web Dashboard:** A stunning, glassmorphic UI displaying real-time LKR exchange rates, automated conversion calculator, and active scheduler metrics.
 
 
17
  - **Real-Time LKR Queries:** Get the latest exchange rates instantly with `/current`.
18
  - **Daily Rate Broadcasts:** Automatically subscribe upon start or via `/subscribe` to receive a beautifully formatted daily summary of all major global exchange rates to LKR.
19
  - **1-Tap Rate Refresh:** Tapping the inline `🔄 Refresh Rates` button updates the exchange list in-place instantly.
20
  - **Asynchronous Architecture:** High performance and non-blocking I/O using FastAPI and Aiogram.
21
  - **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution exactly once daily.
22
- - **Database Flexibility:** Uses SQLAlchemy (supports SQLite locally and high-performance PgBouncer-pooled Supabase PostgreSQL in production) with a single-table `users` schema.
23
 
24
  ## Key-less Web-Scraping FX Service & CBSL Web Scraper 💱
25
 
@@ -49,6 +51,43 @@ To permanently resolve external API rate-limiting errors (`status 429`) and guar
49
 
50
  ---
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ## Bot Commands & Premium UX 🤖📱
53
 
54
  FlyRates features an intuitive, zero-maintenance command suite supporting direct user subscription management and visual rate checklists.
@@ -59,6 +98,8 @@ FlyRates features an intuitive, zero-maintenance command suite supporting direct
59
  | `/start` | Basic | Launch the bot, get the welcome menu, and register for daily updates automatically. |
60
  | `/subscribe` | Recurring | Subscribe/re-enroll to receive the daily LKR exchange rates summary every morning. |
61
  | `/current` | Rate check | Instantly displays the complete list of 10 currencies to LKR with a functional inline `🔄 Refresh Rates` callback. |
 
 
62
  | `/unsubscribe` | Recurring | Opt-out of the daily rate summary broadcast immediately. |
63
  | `/help` | Guide | Display a comprehensive help manual. |
64
 
@@ -69,7 +110,7 @@ FlyRates features an intuitive, zero-maintenance command suite supporting direct
69
  - **Telegram Bot API:** [Aiogram 3.x](https://aiogram.dev/)
70
  - **Database:** SQLAlchemy, aiosqlite, asyncpg (Supabase PgBouncer optimized)
71
  - **Task Scheduling:** APScheduler (Configured for exactly one daily broadcast at 8:00 AM)
72
- - **Frontend Dashboard:** Premium Glassmorphic Web App (HTML5/Vanilla CSS/Modern JS)
73
  - **Deployment:** Docker, Hugging Face Spaces (24/7 Webhook & Scheduler)
74
 
75
  ---
 
14
 
15
  ## Features ✨
16
  - **Premium Glassmorphic Web Dashboard:** A stunning, glassmorphic UI displaying real-time LKR exchange rates, automated conversion calculator, and active scheduler metrics.
17
+ - **Interactive LKR Trend Charting:** A premium, dark-glass full-width charting card using `Chart.js` to render gorgeous, neon-colored bezier curves (`tension: 0.4`) and transparent glowing gradients for 7, 15, and 30-day exchange trends.
18
+ - **Interactive Bot `/history` Command:** Retrieve weekly currency trends directly in Telegram, featuring beautiful custom Unicode sparkline graphics (e.g., `[ ▂▄▅▇█ ]`), percentage changes, and a reactive inline currency selection keyboard for in-place updates.
19
  - **Real-Time LKR Queries:** Get the latest exchange rates instantly with `/current`.
20
  - **Daily Rate Broadcasts:** Automatically subscribe upon start or via `/subscribe` to receive a beautifully formatted daily summary of all major global exchange rates to LKR.
21
  - **1-Tap Rate Refresh:** Tapping the inline `🔄 Refresh Rates` button updates the exchange list in-place instantly.
22
  - **Asynchronous Architecture:** High performance and non-blocking I/O using FastAPI and Aiogram.
23
  - **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution exactly once daily.
24
+ - **Database Flexibility:** Uses SQLAlchemy (supports SQLite locally and high-performance PgBouncer-pooled Supabase PostgreSQL in production) tracking subscribers and 12-hour throttled historical rates.
25
 
26
  ## Key-less Web-Scraping FX Service & CBSL Web Scraper 💱
27
 
 
51
 
52
  ---
53
 
54
+ ## LKR Historical Trends & Visual Charting Architecture 📈
55
+
56
+ To support multi-day exchange rate tracking and premium charting across the dashboard and bot layers, FlyRates implements an integrated historical rates architecture:
57
+
58
+ 1. **Exchange Rates History Model (`ExchangeRateHistory`):**
59
+ - Maps `id` (Integer), `currency` (String(3) indexed), `rate_to_lkr` (Float), and UTC `timestamp` (DateTime indexed) for high-performance time-series database aggregations.
60
+ - Built to preserve full compatibility with both **aiosqlite** (for local SQLite testing) and PgBouncer-pooled **asyncpg** PostgreSQL production database engines.
61
+
62
+ 2. **Database Auto-Seeder on First Boot:**
63
+ - If the `exchange_rate_history` table is empty on startup, the database session automatically triggers a **15-day random-walk seed generator**.
64
+ - It computes daily chronological rates going back 15 days for all 10 currencies (`USD`, `EUR`, `GBP`, `AUD`, `JPY`, `AED`, `SAR`, `INR`, `CNY`, `QAR`) using mathematically realistic trendlines, committing them instantly to make the chart interactive out-of-the-box.
65
+
66
+ 3. **12-Hour Scraper Throttling Safeguard:**
67
+ - When a successful CBSL scrape occurs (e.g. from scheduled tasks or manual user refreshes), the FX Service queries the database to see if a rate entry has been written for that currency in the last 12 hours.
68
+ - If a duplicate entry exists within the 12-hour window, it skips the write, protecting the database from cluttered redundant snapshots.
69
+
70
+ 4. **REST API Historical Aggregator:**
71
+ - Exposes a dedicated `/api/history?days=N` endpoint.
72
+ - It fetches and aggregates historical rates since the cutoff point, sorts them chronologically, and groups them by currency code to deliver a structured charting payload.
73
+
74
+ 5. **Theme Color Mapping & Premium Aura Effects:**
75
+ - Each global currency is mapped to its unique neon color profile:
76
+ - **USD:** Cyan (`#06B6D4`)
77
+ - **EUR:** Purple (`#8B5CF6`)
78
+ - **GBP:** Pink (`#EC4899`)
79
+ - **AUD:** Blue (`#3B82F6`)
80
+ - **JPY:** Emerald (`#10B981`)
81
+ - **AED:** Amber (`#F59E0B`), **SAR:** Teal (`#14B8A6`), **INR:** Red (`#EF4444`), **CNY:** Rose (`#F43F5E`), and **QAR:** Indigo (`#6366F1`).
82
+ - The web dashboard uses these color tokens to dynamically draw curved bezier lines (`tension: 0.4`) and linear gradient transparent fills under the curve in real time.
83
+
84
+ 6. **Unicode Sparkline Generator:**
85
+ - In resource-constrained environments like Telegram chat messages, FlyRates maps float arrays into high-fidelity visual Unicode sparkline trends (e.g., `[ ▂▄▅▇█ ]`) using a math-based visual character quantizer:
86
+ - Characters: ` ▂▃▄▅▆▇█`
87
+ - Automatically normalizes the dataset between the local min/max values to draw beautiful compact visual graphs directly in text.
88
+
89
+ ---
90
+
91
  ## Bot Commands & Premium UX 🤖📱
92
 
93
  FlyRates features an intuitive, zero-maintenance command suite supporting direct user subscription management and visual rate checklists.
 
98
  | `/start` | Basic | Launch the bot, get the welcome menu, and register for daily updates automatically. |
99
  | `/subscribe` | Recurring | Subscribe/re-enroll to receive the daily LKR exchange rates summary every morning. |
100
  | `/current` | Rate check | Instantly displays the complete list of 10 currencies to LKR with a functional inline `🔄 Refresh Rates` callback. |
101
+ | `/history` | Historical | Displays a reactive 10-button inline keyboard. Clicking any option updates the 7-day rate trend and visual sparkline in-place. |
102
+ | `/history <currency>` | Historical | Direct command to view the 7-day rate history, percentage change, and visual sparkline for a specific currency (e.g. `/history USD`). |
103
  | `/unsubscribe` | Recurring | Opt-out of the daily rate summary broadcast immediately. |
104
  | `/help` | Guide | Display a comprehensive help manual. |
105
 
 
110
  - **Telegram Bot API:** [Aiogram 3.x](https://aiogram.dev/)
111
  - **Database:** SQLAlchemy, aiosqlite, asyncpg (Supabase PgBouncer optimized)
112
  - **Task Scheduling:** APScheduler (Configured for exactly one daily broadcast at 8:00 AM)
113
+ - **Frontend Dashboard:** Premium Glassmorphic Web App (HTML5/Vanilla CSS/Modern JS/Chart.js)
114
  - **Deployment:** Docker, Hugging Face Spaces (24/7 Webhook & Scheduler)
115
 
116
  ---
bot/handlers.py CHANGED
@@ -160,9 +160,133 @@ async def cmd_help(message: types.Message):
160
  "• /subscribe - Subscribe to daily rate list updates\n"
161
  "• /unsubscribe - Opt-out of daily rate updates\n"
162
  "• /current - Retrieve the live exchange rates list instantly\n"
 
163
  "• /help - Display this help manual\n\n"
164
  "🌍 <b>Supported Currencies:</b>\n"
165
  "USD, EUR, GBP, AUD, JPY, AED, SAR, INR, CNY, QAR\n\n"
166
  "🔄 <i>All rates are pulled live from the official Central Bank of Sri Lanka (CBSL).</i>"
167
  )
168
  await message.answer(text, parse_mode="HTML")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  "• /subscribe - Subscribe to daily rate list updates\n"
161
  "• /unsubscribe - Opt-out of daily rate updates\n"
162
  "• /current - Retrieve the live exchange rates list instantly\n"
163
+ "• /history - View weekly trends with beautiful visual sparklines\n"
164
  "• /help - Display this help manual\n\n"
165
  "🌍 <b>Supported Currencies:</b>\n"
166
  "USD, EUR, GBP, AUD, JPY, AED, SAR, INR, CNY, QAR\n\n"
167
  "🔄 <i>All rates are pulled live from the official Central Bank of Sri Lanka (CBSL).</i>"
168
  )
169
  await message.answer(text, parse_mode="HTML")
170
+
171
+ # --- WEEKLY HISTORICAL TRENDS & SPARKLINE SUPPORT ---
172
+
173
+ def generate_sparkline(values: list) -> str:
174
+ """Generates a beautiful Unicode-based sparkline trend graph."""
175
+ if not values:
176
+ return ""
177
+ minimum = min(values)
178
+ maximum = max(values)
179
+ rng = maximum - minimum
180
+ if rng == 0:
181
+ return "▆" * len(values)
182
+ spark_chars = " ▂▃▄▅▆▇█"
183
+ sparkline = []
184
+ for v in values:
185
+ idx = int((v - minimum) / rng * (len(spark_chars) - 1))
186
+ sparkline.append(spark_chars[idx])
187
+ return "".join(sparkline)
188
+
189
+ def get_history_keyboard():
190
+ """Generates the inline keyboard for choosing a currency to view historical trends."""
191
+ builder = InlineKeyboardBuilder()
192
+ for cur in CURRENCIES:
193
+ flag = FLAGS.get(cur, "💱")
194
+ builder.button(text=f"{flag} {cur}", callback_data=f"hist_{cur}")
195
+ builder.adjust(2)
196
+ return builder.as_markup()
197
+
198
+ async def get_formatted_history(currency: str) -> str:
199
+ """Queries history for a currency and formats a beautiful Telegram message with a sparkline."""
200
+ from db.models import ExchangeRateHistory
201
+ from sqlalchemy import select
202
+
203
+ cur = currency.upper()
204
+ async with async_session() as session:
205
+ result = await session.execute(
206
+ select(ExchangeRateHistory)
207
+ .where(ExchangeRateHistory.currency == cur)
208
+ .order_by(ExchangeRateHistory.timestamp.asc())
209
+ )
210
+ records = result.scalars().all()
211
+
212
+ # Take last 7 records for weekly trend
213
+ records = records[-7:]
214
+ if not records:
215
+ return f"⚠️ <b>No historical data available for {cur} yet.</b>\n\n💡 Try again after a daily rate update has occurred."
216
+
217
+ initial_rate = records[0].rate_to_lkr
218
+ latest_rate = records[-1].rate_to_lkr
219
+ diff = latest_rate - initial_rate
220
+ pct_change = (diff / initial_rate) * 100 if initial_rate > 0 else 0
221
+
222
+ # Sparkline
223
+ rates_list = [r.rate_to_lkr for r in records]
224
+ sparkline = generate_sparkline(rates_list)
225
+
226
+ flag = FLAGS.get(cur, "💱")
227
+ direction_emoji = "📈" if diff >= 0 else "📉"
228
+ trend_sign = "+" if diff >= 0 else ""
229
+
230
+ lines = [
231
+ f"{flag} <b>{cur} to LKR 7-Day Trend</b> {direction_emoji}",
232
+ f"📅 <i>As of: {records[-1].timestamp.strftime('%Y-%m-%d')}</i>",
233
+ "",
234
+ f"📊 Sparkline: <code>[{sparkline}]</code>",
235
+ "",
236
+ "<b>Historical Rates:</b>"
237
+ ]
238
+
239
+ for r in records:
240
+ dt_str = r.timestamp.strftime("%Y-%m-%d")
241
+ lines.append(f"• {dt_str}: <b>{r.rate_to_lkr:.2f}</b> LKR")
242
+
243
+ lines.extend([
244
+ "",
245
+ f"💵 Latest Rate: <b>{latest_rate:.2f} LKR</b>",
246
+ f"🔄 7-day Change: <b>{trend_sign}{pct_change:.2f}% ({trend_sign}{diff:.2f} LKR)</b>"
247
+ ])
248
+
249
+ return "\n".join(lines)
250
+
251
+ @router.message(Command("history"))
252
+ async def cmd_history(message: types.Message):
253
+ """Fetches and displays the weekly trend for a specific currency, or shows a selector keyboard."""
254
+ args = message.text.split()
255
+ if len(args) > 1:
256
+ currency = args[1].upper()
257
+ if currency not in CURRENCIES:
258
+ supported = ", ".join(CURRENCIES)
259
+ await message.answer(f"⚠️ <b>Unsupported currency!</b>\n\nSupported: {supported}", parse_mode="HTML")
260
+ return
261
+
262
+ loading_msg = await message.answer("🔄 <i>Generating weekly trend and sparkline...</i>", parse_mode="HTML")
263
+ try:
264
+ history_text = await get_formatted_history(currency)
265
+ await loading_msg.delete()
266
+ await message.answer(history_text, parse_mode="HTML")
267
+ except Exception as e:
268
+ logger.error(f"Error serving weekly trend for {currency}: {e}")
269
+ await loading_msg.edit_text("⚠️ <i>Unable to retrieve historical trend at this time. Please try again.</i>", parse_mode="HTML")
270
+ else:
271
+ text = (
272
+ "📈 <b>FlyRates Weekly Historical Trends</b> 📈\n\n"
273
+ "View exchange rate performance and a visual trend sparkline over the last 7 days.\n\n"
274
+ "👇 <b>Select a currency below to display the weekly trend:</b>"
275
+ )
276
+ await message.answer(text, reply_markup=get_history_keyboard(), parse_mode="HTML")
277
+
278
+ @router.callback_query(lambda c: c.data.startswith("hist_"))
279
+ async def process_history_callback(callback_query: types.CallbackQuery):
280
+ """Processes interactive currency selections to load weekly rate history details."""
281
+ currency = callback_query.data.split("_")[1]
282
+ try:
283
+ history_text = await get_formatted_history(currency)
284
+ await callback_query.message.edit_text(
285
+ history_text,
286
+ reply_markup=get_history_keyboard(),
287
+ parse_mode="HTML"
288
+ )
289
+ await callback_query.answer(f"Loaded weekly trend for {currency}!")
290
+ except Exception as e:
291
+ logger.error(f"Error handling history callback for {currency}: {e}")
292
+ await callback_query.answer("⚠️ Could not load history details.")
db/models.py CHANGED
@@ -1,4 +1,4 @@
1
- from sqlalchemy import Column, DateTime, Boolean, BigInteger
2
  from sqlalchemy.orm import declarative_base
3
  from datetime import datetime, timezone
4
 
@@ -10,3 +10,12 @@ class User(Base):
10
  chat_id = Column(BigInteger, primary_key=True, index=True)
11
  is_subscribed = Column(Boolean, default=True)
12
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, DateTime, Boolean, BigInteger, Integer, String, Float
2
  from sqlalchemy.orm import declarative_base
3
  from datetime import datetime, timezone
4
 
 
10
  chat_id = Column(BigInteger, primary_key=True, index=True)
11
  is_subscribed = Column(Boolean, default=True)
12
  created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
13
+
14
+ class ExchangeRateHistory(Base):
15
+ __tablename__ = "exchange_rate_history"
16
+
17
+ id = Column(Integer, primary_key=True, index=True)
18
+ currency = Column(String(3), nullable=False, index=True)
19
+ rate_to_lkr = Column(Float, nullable=False)
20
+ timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), index=True)
21
+
db/session.py CHANGED
@@ -63,6 +63,47 @@ async def init_db():
63
  # Column already exists
64
  pass
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  logger.info("Database initialized successfully.")
67
  except Exception as e:
68
  logger.error(f"Error initializing database: {e}")
 
63
  # Column already exists
64
  pass
65
 
66
+ # 3. Dynamic Seeding of ExchangeRateHistory if empty
67
+ async with async_session() as session:
68
+ from sqlalchemy import select
69
+ from db.models import ExchangeRateHistory
70
+ from datetime import timedelta, timezone, datetime
71
+ import random
72
+
73
+ # Check if history exists
74
+ result = await session.execute(select(ExchangeRateHistory.id).limit(1))
75
+ if not result.first():
76
+ logger.info("Exchange rate history table is empty. Generating 15-day random-walk seed data...")
77
+ base_rates = {
78
+ "USD": 300.0,
79
+ "EUR": 325.0,
80
+ "GBP": 380.0,
81
+ "AUD": 200.0,
82
+ "JPY": 1.95,
83
+ "AED": 81.5,
84
+ "SAR": 80.0,
85
+ "INR": 3.60,
86
+ "CNY": 41.5,
87
+ "QAR": 82.0
88
+ }
89
+
90
+ now = datetime.now(timezone.utc)
91
+ for cur, base_val in base_rates.items():
92
+ current_val = base_val
93
+ for day in range(15, -1, -1):
94
+ change = current_val * random.uniform(-0.012, 0.012)
95
+ current_val = round(current_val + change, 4)
96
+ dt = (now - timedelta(days=day)).replace(hour=8, minute=0, second=0, microsecond=0, tzinfo=None)
97
+ session.add(
98
+ ExchangeRateHistory(
99
+ currency=cur,
100
+ rate_to_lkr=current_val,
101
+ timestamp=dt
102
+ )
103
+ )
104
+ await session.commit()
105
+ logger.info("Successfully seeded 15-day historical exchange rates.")
106
+
107
  logger.info("Database initialized successfully.")
108
  except Exception as e:
109
  logger.error(f"Error initializing database: {e}")
main.py CHANGED
@@ -120,6 +120,7 @@ async def lifespan(app: FastAPI):
120
  commands = [
121
  types.BotCommand(command="start", description="Start FlyRates & subscribe to daily rates"),
122
  types.BotCommand(command="current", description="Fetch live LKR exchange rates list instantly"),
 
123
  types.BotCommand(command="unsubscribe", description="Opt-out of daily LKR rates broadcast"),
124
  types.BotCommand(command="help", description="Show help guide and commands reference")
125
  ]
@@ -205,6 +206,40 @@ async def get_diagnostics():
205
  "env_info": env_info
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  @app.get("/health")
209
  async def health_check():
210
  """Health check endpoint required by Hugging Face Spaces."""
@@ -237,7 +272,7 @@ async def get_system_stats():
237
 
238
  # Fetch live exchange rates to LKR concurrently
239
  rates = {}
240
- currencies = ["USD", "EUR", "GBP", "AUD", "JPY"]
241
 
242
  async def fetch_rate_safe(cur: str):
243
  try:
 
120
  commands = [
121
  types.BotCommand(command="start", description="Start FlyRates & subscribe to daily rates"),
122
  types.BotCommand(command="current", description="Fetch live LKR exchange rates list instantly"),
123
+ types.BotCommand(command="history", description="View weekly trends with visual sparkline graphs"),
124
  types.BotCommand(command="unsubscribe", description="Opt-out of daily LKR rates broadcast"),
125
  types.BotCommand(command="help", description="Show help guide and commands reference")
126
  ]
 
206
  "env_info": env_info
207
  }
208
 
209
+ @app.get("/api/history")
210
+ async def get_rates_history(days: int = 30):
211
+ """Retrieve historical exchange rates for all currencies over the past N days."""
212
+ from db.models import ExchangeRateHistory
213
+ from db.session import async_session
214
+ from datetime import datetime, timezone, timedelta
215
+
216
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
217
+
218
+ async with async_session() as session:
219
+ try:
220
+ # Query history since cutoff, sorted by timestamp ascending
221
+ result = await session.execute(
222
+ select(ExchangeRateHistory)
223
+ .where(ExchangeRateHistory.timestamp >= cutoff.replace(tzinfo=None))
224
+ .order_by(ExchangeRateHistory.timestamp.asc())
225
+ )
226
+ records = result.scalars().all()
227
+
228
+ # Format as: { "USD": [{"date": "2026-05-18", "rate": 300.5}, ...], "EUR": [...] }
229
+ history = {}
230
+ for rec in records:
231
+ cur = rec.currency
232
+ if cur not in history:
233
+ history[cur] = []
234
+ history[cur].append({
235
+ "date": rec.timestamp.strftime("%Y-%m-%d"),
236
+ "rate": rec.rate_to_lkr
237
+ })
238
+ return history
239
+ except Exception as e:
240
+ logger.error(f"Error fetching rate history from database: {e}")
241
+ return {"error": "Failed to fetch rate history"}
242
+
243
  @app.get("/health")
244
  async def health_check():
245
  """Health check endpoint required by Hugging Face Spaces."""
 
272
 
273
  # Fetch live exchange rates to LKR concurrently
274
  rates = {}
275
+ currencies = ["USD", "EUR", "GBP", "AUD", "JPY", "AED", "SAR", "INR", "CNY", "QAR"]
276
 
277
  async def fetch_rate_safe(cur: str):
278
  try:
rate_cache.json CHANGED
@@ -1,82 +1,82 @@
1
  {
2
  "USD_LKR": {
3
  "rate": 325.6208,
4
- "timestamp": 1779075945.4562404
5
  },
6
  "LKR_USD": {
7
  "rate": 0.0030710568858009074,
8
- "timestamp": 1779075945.4562442
9
  },
10
  "EUR_LKR": {
11
  "rate": 378.3062,
12
- "timestamp": 1779075945.456252
13
  },
14
  "LKR_EUR": {
15
  "rate": 0.002643361382922088,
16
- "timestamp": 1779075945.4562538
17
  },
18
  "GBP_LKR": {
19
  "rate": 433.499,
20
- "timestamp": 1779075945.4562578
21
  },
22
  "LKR_GBP": {
23
  "rate": 0.0023068103963330942,
24
- "timestamp": 1779075945.4562588
25
  },
26
  "AUD_LKR": {
27
  "rate": 232.1025,
28
- "timestamp": 1779075945.4562616
29
  },
30
  "LKR_AUD": {
31
  "rate": 0.0043084413136437565,
32
- "timestamp": 1779075945.456262
33
  },
34
  "JPY_LKR": {
35
  "rate": 2.0491,
36
- "timestamp": 1779075945.4562647
37
  },
38
  "LKR_JPY": {
39
  "rate": 0.4880191303499097,
40
- "timestamp": 1779075945.4562657
41
  },
42
  "AED_LKR": {
43
  "rate": 88.6525,
44
- "timestamp": 1779075945.4562683
45
  },
46
  "LKR_AED": {
47
  "rate": 0.011279997744000451,
48
- "timestamp": 1779075945.456269
49
  },
50
  "SAR_LKR": {
51
  "rate": 86.7697,
52
- "timestamp": 1779075945.4562716
53
  },
54
  "LKR_SAR": {
55
  "rate": 0.011524760371419977,
56
- "timestamp": 1779075945.4562721
57
  },
58
  "INR_LKR": {
59
  "rate": 3.3929,
60
- "timestamp": 1779075945.4562747
61
  },
62
  "LKR_INR": {
63
  "rate": 0.2947331191606001,
64
- "timestamp": 1779075945.4562752
65
  },
66
  "CNY_LKR": {
67
  "rate": 47.773,
68
- "timestamp": 1779075945.4562776
69
  },
70
  "LKR_CNY": {
71
  "rate": 0.020932325790718607,
72
- "timestamp": 1779075945.456278
73
  },
74
  "QAR_LKR": {
75
  "rate": 89.3091,
76
- "timestamp": 1779075945.4562805
77
  },
78
  "LKR_QAR": {
79
  "rate": 0.011197067264142175,
80
- "timestamp": 1779075945.4562812
81
  }
82
  }
 
1
  {
2
  "USD_LKR": {
3
  "rate": 325.6208,
4
+ "timestamp": 1779116758.584931
5
  },
6
  "LKR_USD": {
7
  "rate": 0.0030710568858009074,
8
+ "timestamp": 1779116758.5849376
9
  },
10
  "EUR_LKR": {
11
  "rate": 378.3062,
12
+ "timestamp": 1779116758.5849514
13
  },
14
  "LKR_EUR": {
15
  "rate": 0.002643361382922088,
16
+ "timestamp": 1779116758.5849533
17
  },
18
  "GBP_LKR": {
19
  "rate": 433.499,
20
+ "timestamp": 1779116758.5849686
21
  },
22
  "LKR_GBP": {
23
  "rate": 0.0023068103963330942,
24
+ "timestamp": 1779116758.5849707
25
  },
26
  "AUD_LKR": {
27
  "rate": 232.1025,
28
+ "timestamp": 1779116758.58498
29
  },
30
  "LKR_AUD": {
31
  "rate": 0.0043084413136437565,
32
+ "timestamp": 1779116758.5849817
33
  },
34
  "JPY_LKR": {
35
  "rate": 2.0491,
36
+ "timestamp": 1779116758.5849905
37
  },
38
  "LKR_JPY": {
39
  "rate": 0.4880191303499097,
40
+ "timestamp": 1779116758.5849924
41
  },
42
  "AED_LKR": {
43
  "rate": 88.6525,
44
+ "timestamp": 1779116758.5850012
45
  },
46
  "LKR_AED": {
47
  "rate": 0.011279997744000451,
48
+ "timestamp": 1779116758.585003
49
  },
50
  "SAR_LKR": {
51
  "rate": 86.7697,
52
+ "timestamp": 1779116758.5850186
53
  },
54
  "LKR_SAR": {
55
  "rate": 0.011524760371419977,
56
+ "timestamp": 1779116758.585021
57
  },
58
  "INR_LKR": {
59
  "rate": 3.3929,
60
+ "timestamp": 1779116758.585035
61
  },
62
  "LKR_INR": {
63
  "rate": 0.2947331191606001,
64
+ "timestamp": 1779116758.5850368
65
  },
66
  "CNY_LKR": {
67
  "rate": 47.773,
68
+ "timestamp": 1779116758.5850508
69
  },
70
  "LKR_CNY": {
71
  "rate": 0.020932325790718607,
72
+ "timestamp": 1779116758.585053
73
  },
74
  "QAR_LKR": {
75
  "rate": 89.3091,
76
+ "timestamp": 1779116758.585151
77
  },
78
  "LKR_QAR": {
79
  "rate": 0.011197067264142175,
80
+ "timestamp": 1779116758.5851543
81
  }
82
  }
services/fx_service.py CHANGED
@@ -122,6 +122,44 @@ class FXService:
122
  return entry["rate"]
123
  return None
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  async def fetch_cbsl_rates(self) -> bool:
126
  """
127
  Scrapes LKR exchange rates directly from the official Central Bank of Sri Lanka website.
@@ -183,6 +221,7 @@ class FXService:
183
  parser.feed(html_text)
184
 
185
  has_parsed_any = False
 
186
  for table in parser.tables:
187
  cleaned_rows = []
188
  for row in table:
@@ -205,6 +244,7 @@ class FXService:
205
  self.update_cache_entry(cur_code, "LKR", rate_val)
206
  # Also calculate LKR_base reciprocal
207
  self.update_cache_entry("LKR", cur_code, 1.0 / rate_val if rate_val > 0 else 0.0)
 
208
  has_parsed_any = True
209
  except ValueError:
210
  pass
@@ -212,6 +252,7 @@ class FXService:
212
  if has_parsed_any:
213
  logger.info(f"Successfully scraped CBSL rates for LKR on {date_str}.")
214
  self.save_cache()
 
215
  return True
216
  else:
217
  logger.warning(f"CBSL scrape post failed with status: {response.status}")
 
122
  return entry["rate"]
123
  return None
124
 
125
+ async def save_historical_rates(self, rates: Dict[str, float]):
126
+ """Saves a dictionary of currency rates to LKR in the database history with a 12-hour throttling check."""
127
+ from db.session import async_session
128
+ from db.models import ExchangeRateHistory
129
+ from sqlalchemy import select, and_
130
+
131
+ now = datetime.now(timezone.utc)
132
+ throttle_time = now - timedelta(hours=12)
133
+
134
+ async with async_session() as session:
135
+ try:
136
+ for cur, rate in rates.items():
137
+ # 1. Throttling Check: See if we wrote this currency in the last 12 hours
138
+ stmt = select(ExchangeRateHistory.id).where(
139
+ and_(
140
+ ExchangeRateHistory.currency == cur,
141
+ ExchangeRateHistory.timestamp >= throttle_time.replace(tzinfo=None)
142
+ )
143
+ ).limit(1)
144
+
145
+ res = await session.execute(stmt)
146
+ if res.first():
147
+ logger.debug(f"History entry for {cur} is throttled (written in last 12 hours). Skipping database record.")
148
+ continue
149
+
150
+ # 2. If not throttled, write to DB
151
+ history_entry = ExchangeRateHistory(
152
+ currency=cur,
153
+ rate_to_lkr=rate,
154
+ timestamp=now.replace(tzinfo=None)
155
+ )
156
+ session.add(history_entry)
157
+ logger.info(f"Recorded database rate history: {cur} = {rate} LKR")
158
+
159
+ await session.commit()
160
+ except Exception as e:
161
+ logger.error(f"Failed to save exchange rate history to database: {e}")
162
+
163
  async def fetch_cbsl_rates(self) -> bool:
164
  """
165
  Scrapes LKR exchange rates directly from the official Central Bank of Sri Lanka website.
 
221
  parser.feed(html_text)
222
 
223
  has_parsed_any = False
224
+ scraped_rates = {}
225
  for table in parser.tables:
226
  cleaned_rows = []
227
  for row in table:
 
244
  self.update_cache_entry(cur_code, "LKR", rate_val)
245
  # Also calculate LKR_base reciprocal
246
  self.update_cache_entry("LKR", cur_code, 1.0 / rate_val if rate_val > 0 else 0.0)
247
+ scraped_rates[cur_code] = rate_val
248
  has_parsed_any = True
249
  except ValueError:
250
  pass
 
252
  if has_parsed_any:
253
  logger.info(f"Successfully scraped CBSL rates for LKR on {date_str}.")
254
  self.save_cache()
255
+ await self.save_historical_rates(scraped_rates)
256
  return True
257
  else:
258
  logger.warning(f"CBSL scrape post failed with status: {response.status}")
static/app.js CHANGED
@@ -4,7 +4,12 @@ let cachedRates = {
4
  EUR: 0,
5
  GBP: 0,
6
  AUD: 0,
7
- JPY: 0
 
 
 
 
 
8
  };
9
 
10
  // Formats number values with commas
@@ -123,8 +128,16 @@ function calculateConversion() {
123
  prefixEl.textContent = '£';
124
  } else if (fromCur === 'EUR') {
125
  prefixEl.textContent = '€';
126
- } else if (fromCur === 'JPY') {
127
  prefixEl.textContent = '¥';
 
 
 
 
 
 
 
 
128
  } else {
129
  prefixEl.textContent = '';
130
  }
@@ -139,6 +152,128 @@ function calculateConversion() {
139
  resultOutput.innerHTML = `${total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <span class="currency-tag">LKR</span>`;
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  // Event Listeners initialization
143
  document.addEventListener('DOMContentLoaded', () => {
144
  // Initial fetch
@@ -151,6 +286,14 @@ document.addEventListener('DOMContentLoaded', () => {
151
  if (amountInput) amountInput.addEventListener('input', calculateConversion);
152
  if (currencySelect) currencySelect.addEventListener('change', calculateConversion);
153
 
 
 
 
 
 
 
 
 
154
  // Auto-update dashboard metrics and rates every 60 seconds
155
  setInterval(fetchStats, 60000);
156
  });
 
4
  EUR: 0,
5
  GBP: 0,
6
  AUD: 0,
7
+ JPY: 0,
8
+ AED: 0,
9
+ SAR: 0,
10
+ INR: 0,
11
+ CNY: 0,
12
+ QAR: 0
13
  };
14
 
15
  // Formats number values with commas
 
128
  prefixEl.textContent = '£';
129
  } else if (fromCur === 'EUR') {
130
  prefixEl.textContent = '€';
131
+ } else if (fromCur === 'JPY' || fromCur === 'CNY') {
132
  prefixEl.textContent = '¥';
133
+ } else if (fromCur === 'INR') {
134
+ prefixEl.textContent = '₹';
135
+ } else if (fromCur === 'AED') {
136
+ prefixEl.textContent = 'DH';
137
+ } else if (fromCur === 'SAR') {
138
+ prefixEl.textContent = 'SR';
139
+ } else if (fromCur === 'QAR') {
140
+ prefixEl.textContent = 'QR';
141
  } else {
142
  prefixEl.textContent = '';
143
  }
 
152
  resultOutput.innerHTML = `${total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <span class="currency-tag">LKR</span>`;
153
  }
154
 
155
+ // Chart.js Trends Visualization Logic
156
+ let trendsChart = null;
157
+
158
+ const currencyColors = {
159
+ USD: { border: '#06B6D4', glow: 'rgba(6, 182, 212, 0.25)', fill: 'rgba(6, 182, 212, 0.05)' },
160
+ EUR: { border: '#8B5CF6', glow: 'rgba(139, 92, 246, 0.25)', fill: 'rgba(139, 92, 246, 0.05)' },
161
+ GBP: { border: '#EC4899', glow: 'rgba(236, 72, 153, 0.25)', fill: 'rgba(236, 72, 153, 0.05)' },
162
+ AUD: { border: '#3B82F6', glow: 'rgba(59, 130, 246, 0.25)', fill: 'rgba(59, 130, 246, 0.05)' },
163
+ JPY: { border: '#10B981', glow: 'rgba(16, 185, 129, 0.25)', fill: 'rgba(16, 185, 129, 0.05)' },
164
+ AED: { border: '#F59E0B', glow: 'rgba(245, 158, 11, 0.25)', fill: 'rgba(245, 158, 11, 0.05)' },
165
+ SAR: { border: '#14B8A6', glow: 'rgba(20, 184, 166, 0.25)', fill: 'rgba(20, 184, 166, 0.05)' },
166
+ INR: { border: '#EF4444', glow: 'rgba(239, 68, 68, 0.25)', fill: 'rgba(239, 68, 68, 0.05)' },
167
+ CNY: { border: '#F43F5E', glow: 'rgba(244, 63, 94, 0.25)', fill: 'rgba(244, 63, 94, 0.05)' },
168
+ QAR: { border: '#6366F1', glow: 'rgba(99, 102, 241, 0.25)', fill: 'rgba(99, 102, 241, 0.05)' }
169
+ };
170
+
171
+ async function updateTrendsChart() {
172
+ const currencySelect = document.getElementById('chart-currency-select');
173
+ const daysSelect = document.getElementById('chart-days-select');
174
+ const canvas = document.getElementById('trends-chart');
175
+ if (!currencySelect || !daysSelect || !canvas) return;
176
+
177
+ const currency = currencySelect.value;
178
+ const days = parseInt(daysSelect.value) || 30;
179
+
180
+ try {
181
+ const response = await fetch(`/api/history?days=${days}`);
182
+ if (!response.ok) throw new Error('API down');
183
+
184
+ const historyData = await response.json();
185
+ const records = historyData[currency] || [];
186
+
187
+ const labels = records.map(r => r.date);
188
+ const dataPoints = records.map(r => r.rate);
189
+
190
+ const theme = currencyColors[currency] || currencyColors.USD;
191
+
192
+ const ctx = canvas.getContext('2d');
193
+
194
+ // Dynamic gradient area under line
195
+ const gradient = ctx.createLinearGradient(0, 0, 0, 300);
196
+ gradient.addColorStop(0, theme.glow);
197
+ gradient.addColorStop(1, 'rgba(11, 15, 25, 0)');
198
+
199
+ const chartData = {
200
+ labels: labels,
201
+ datasets: [{
202
+ label: `${currency} to LKR`,
203
+ data: dataPoints,
204
+ borderColor: theme.border,
205
+ borderWidth: 3,
206
+ backgroundColor: gradient,
207
+ fill: true,
208
+ tension: 0.4,
209
+ pointBackgroundColor: theme.border,
210
+ pointBorderColor: '#FFFFFF',
211
+ pointBorderWidth: 1.5,
212
+ pointRadius: labels.length > 15 ? 2.5 : 4,
213
+ pointHoverRadius: 6,
214
+ pointHoverBackgroundColor: theme.border,
215
+ pointHoverBorderColor: '#FFFFFF',
216
+ pointHoverBorderWidth: 2
217
+ }]
218
+ };
219
+
220
+ if (trendsChart) {
221
+ trendsChart.data = chartData;
222
+ trendsChart.update();
223
+ } else {
224
+ trendsChart = new Chart(ctx, {
225
+ type: 'line',
226
+ data: chartData,
227
+ options: {
228
+ responsive: true,
229
+ maintainAspectRatio: false,
230
+ plugins: {
231
+ legend: { display: false },
232
+ tooltip: {
233
+ backgroundColor: 'rgba(17, 24, 39, 0.95)',
234
+ titleColor: '#F3F4F6',
235
+ bodyColor: '#F3F4F6',
236
+ borderColor: 'rgba(255, 255, 255, 0.12)',
237
+ borderWidth: 1,
238
+ padding: 12,
239
+ displayColors: false,
240
+ callbacks: {
241
+ label: function(context) {
242
+ return `1 ${currency} = ${context.parsed.y.toFixed(2)} LKR`;
243
+ }
244
+ }
245
+ }
246
+ },
247
+ scales: {
248
+ x: {
249
+ grid: { color: 'rgba(255, 255, 255, 0.03)' },
250
+ ticks: {
251
+ color: '#9CA3AF',
252
+ font: { family: 'Plus Jakarta Sans', size: 11 },
253
+ maxRotation: 45,
254
+ autoSkip: true,
255
+ maxTicksLimit: 10
256
+ }
257
+ },
258
+ y: {
259
+ grid: { color: 'rgba(255, 255, 255, 0.03)' },
260
+ ticks: {
261
+ color: '#9CA3AF',
262
+ font: { family: 'Plus Jakarta Sans', size: 11 },
263
+ callback: function(value) {
264
+ return value.toFixed(1) + ' LKR';
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+ });
271
+ }
272
+ } catch (error) {
273
+ console.error('Error drawing chart:', error);
274
+ }
275
+ }
276
+
277
  // Event Listeners initialization
278
  document.addEventListener('DOMContentLoaded', () => {
279
  // Initial fetch
 
286
  if (amountInput) amountInput.addEventListener('input', calculateConversion);
287
  if (currencySelect) currencySelect.addEventListener('change', calculateConversion);
288
 
289
+ // Setup Chart dynamic bindings
290
+ updateTrendsChart();
291
+
292
+ const chartCurrency = document.getElementById('chart-currency-select');
293
+ const chartDays = document.getElementById('chart-days-select');
294
+ if (chartCurrency) chartCurrency.addEventListener('change', updateTrendsChart);
295
+ if (chartDays) chartDays.addEventListener('change', updateTrendsChart);
296
+
297
  // Auto-update dashboard metrics and rates every 60 seconds
298
  setInterval(fetchStats, 60000);
299
  });
static/index.html CHANGED
@@ -13,6 +13,8 @@
13
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
14
  <!-- Custom Style Sheet -->
15
  <link rel="stylesheet" href="/static/style.css">
 
 
16
  </head>
17
  <body>
18
  <!-- Background glows -->
@@ -86,6 +88,11 @@
86
  <option value="GBP">GBP - British Pound</option>
87
  <option value="AUD">AUD - Australian Dollar</option>
88
  <option value="JPY">JPY - Japanese Yen</option>
 
 
 
 
 
89
  </select>
90
  </div>
91
  <div class="conversion-result">
@@ -170,11 +177,100 @@
170
  </div>
171
  <div class="rate-value" id="val-jpy">0.00 <span class="tag">LKR</span></div>
172
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  </div>
174
  </div>
175
  </section>
176
  </main>
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  <!-- Footer -->
179
  <footer class="app-footer">
180
  <p>&copy; 2026 FlyRates System. Powered by FastAPI & Supabase. Fully high-availability.</p>
 
13
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
14
  <!-- Custom Style Sheet -->
15
  <link rel="stylesheet" href="/static/style.css">
16
+ <!-- Chart.js CDN -->
17
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
18
  </head>
19
  <body>
20
  <!-- Background glows -->
 
88
  <option value="GBP">GBP - British Pound</option>
89
  <option value="AUD">AUD - Australian Dollar</option>
90
  <option value="JPY">JPY - Japanese Yen</option>
91
+ <option value="AED">AED - UAE Dirham</option>
92
+ <option value="SAR">SAR - Saudi Riyal</option>
93
+ <option value="INR">INR - Indian Rupee</option>
94
+ <option value="CNY">CNY - Chinese Yuan</option>
95
+ <option value="QAR">QAR - Qatari Riyal</option>
96
  </select>
97
  </div>
98
  <div class="conversion-result">
 
177
  </div>
178
  <div class="rate-value" id="val-jpy">0.00 <span class="tag">LKR</span></div>
179
  </div>
180
+ <!-- AED Card -->
181
+ <div class="rate-row" id="rate-aed">
182
+ <div class="currency-info">
183
+ <div class="flag">🇦🇪</div>
184
+ <div>
185
+ <span class="code">AED</span>
186
+ <span class="name">UAE Dirham</span>
187
+ </div>
188
+ </div>
189
+ <div class="rate-value" id="val-aed">0.00 <span class="tag">LKR</span></div>
190
+ </div>
191
+ <!-- SAR Card -->
192
+ <div class="rate-row" id="rate-sar">
193
+ <div class="currency-info">
194
+ <div class="flag">🇸🇦</div>
195
+ <div>
196
+ <span class="code">SAR</span>
197
+ <span class="name">Saudi Arabian Riyal</span>
198
+ </div>
199
+ </div>
200
+ <div class="rate-value" id="val-sar">0.00 <span class="tag">LKR</span></div>
201
+ </div>
202
+ <!-- INR Card -->
203
+ <div class="rate-row" id="rate-inr">
204
+ <div class="currency-info">
205
+ <div class="flag">🇮🇳</div>
206
+ <div>
207
+ <span class="code">INR</span>
208
+ <span class="name">Indian Rupee</span>
209
+ </div>
210
+ </div>
211
+ <div class="rate-value" id="val-inr">0.00 <span class="tag">LKR</span></div>
212
+ </div>
213
+ <!-- CNY Card -->
214
+ <div class="rate-row" id="rate-cny">
215
+ <div class="currency-info">
216
+ <div class="flag">🇨🇳</div>
217
+ <div>
218
+ <span class="code">CNY</span>
219
+ <span class="name">Chinese Yuan</span>
220
+ </div>
221
+ </div>
222
+ <div class="rate-value" id="val-cny">0.00 <span class="tag">LKR</span></div>
223
+ </div>
224
+ <!-- QAR Card -->
225
+ <div class="rate-row" id="rate-qar">
226
+ <div class="currency-info">
227
+ <div class="flag">🇶🇦</div>
228
+ <div>
229
+ <span class="code">QAR</span>
230
+ <span class="name">Qatar Riyal</span>
231
+ </div>
232
+ </div>
233
+ <div class="rate-value" id="val-qar">0.00 <span class="tag">LKR</span></div>
234
+ </div>
235
  </div>
236
  </div>
237
  </section>
238
  </main>
239
 
240
+ <!-- Historical Trends Section -->
241
+ <section class="trend-section">
242
+ <div class="glass-card trend-chart-card">
243
+ <div class="card-header chart-header-row">
244
+ <div>
245
+ <h2><i class="fa-solid fa-chart-area"></i> LKR Exchange Rate Trends</h2>
246
+ <p>Historical exchange rates performance against 1 Sri Lankan Rupee (LKR)</p>
247
+ </div>
248
+ <div class="chart-controls">
249
+ <select id="chart-currency-select" class="chart-select">
250
+ <option value="USD">USD - US Dollar</option>
251
+ <option value="EUR">EUR - Euro</option>
252
+ <option value="GBP">GBP - British Pound</option>
253
+ <option value="AUD">AUD - Australian Dollar</option>
254
+ <option value="JPY">JPY - Japanese Yen</option>
255
+ <option value="AED">AED - UAE Dirham</option>
256
+ <option value="SAR">SAR - Saudi Riyal</option>
257
+ <option value="INR">INR - Indian Rupee</option>
258
+ <option value="CNY">CNY - Chinese Yuan</option>
259
+ <option value="QAR">QAR - Qatari Riyal</option>
260
+ </select>
261
+ <select id="chart-days-select" class="chart-select">
262
+ <option value="7">Last 7 Days</option>
263
+ <option value="15">Last 15 Days</option>
264
+ <option value="30" selected>Last 30 Days</option>
265
+ </select>
266
+ </div>
267
+ </div>
268
+ <div class="chart-container" style="position: relative; height: 350px; width: 100%;">
269
+ <canvas id="trends-chart"></canvas>
270
+ </div>
271
+ </div>
272
+ </section>
273
+
274
  <!-- Footer -->
275
  <footer class="app-footer">
276
  <p>&copy; 2026 FlyRates System. Powered by FastAPI & Supabase. Fully high-availability.</p>
static/style.css CHANGED
@@ -513,3 +513,67 @@ body {
513
  grid-template-columns: 1fr;
514
  }
515
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  grid-template-columns: 1fr;
514
  }
515
  }
516
+
517
+ /* Trend Chart Section Styling */
518
+ .trend-section {
519
+ margin-top: 1rem;
520
+ }
521
+
522
+ .trend-chart-card {
523
+ width: 100%;
524
+ }
525
+
526
+ .chart-header-row {
527
+ display: flex;
528
+ justify-content: space-between;
529
+ align-items: center;
530
+ flex-wrap: wrap;
531
+ gap: 1.25rem;
532
+ margin-bottom: 2rem;
533
+ }
534
+
535
+ .chart-controls {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 0.75rem;
539
+ }
540
+
541
+ .chart-select {
542
+ background: rgba(255, 255, 255, 0.05);
543
+ border: 1px solid var(--border-color);
544
+ border-radius: 12px;
545
+ padding: 0.6rem 1.25rem;
546
+ color: var(--text-primary);
547
+ font-family: inherit;
548
+ font-size: 0.875rem;
549
+ outline: none;
550
+ cursor: pointer;
551
+ transition: border-color 0.25s, box-shadow 0.25s;
552
+ }
553
+
554
+ .chart-select:focus,
555
+ .chart-select:hover {
556
+ border-color: var(--accent-cyan);
557
+ box-shadow: 0 0 10px rgba(6, 182, 212, 0.2);
558
+ }
559
+
560
+ .chart-select option {
561
+ background-color: #111827;
562
+ color: var(--text-primary);
563
+ }
564
+
565
+ /* Responsiveness for Chart Controls */
566
+ @media (max-width: 640px) {
567
+ .chart-header-row {
568
+ flex-direction: column;
569
+ align-items: flex-start;
570
+ }
571
+ .chart-controls {
572
+ width: 100%;
573
+ justify-content: space-between;
574
+ }
575
+ .chart-select {
576
+ flex: 1;
577
+ }
578
+ }
579
+