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- README.md +43 -2
- bot/handlers.py +124 -0
- db/models.py +10 -1
- db/session.py +41 -0
- main.py +36 -1
- rate_cache.json +20 -20
- services/fx_service.py +41 -0
- static/app.js +145 -2
- static/index.html +96 -0
- 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)
|
| 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":
|
| 5 |
},
|
| 6 |
"LKR_USD": {
|
| 7 |
"rate": 0.0030710568858009074,
|
| 8 |
-
"timestamp":
|
| 9 |
},
|
| 10 |
"EUR_LKR": {
|
| 11 |
"rate": 378.3062,
|
| 12 |
-
"timestamp":
|
| 13 |
},
|
| 14 |
"LKR_EUR": {
|
| 15 |
"rate": 0.002643361382922088,
|
| 16 |
-
"timestamp":
|
| 17 |
},
|
| 18 |
"GBP_LKR": {
|
| 19 |
"rate": 433.499,
|
| 20 |
-
"timestamp":
|
| 21 |
},
|
| 22 |
"LKR_GBP": {
|
| 23 |
"rate": 0.0023068103963330942,
|
| 24 |
-
"timestamp":
|
| 25 |
},
|
| 26 |
"AUD_LKR": {
|
| 27 |
"rate": 232.1025,
|
| 28 |
-
"timestamp":
|
| 29 |
},
|
| 30 |
"LKR_AUD": {
|
| 31 |
"rate": 0.0043084413136437565,
|
| 32 |
-
"timestamp":
|
| 33 |
},
|
| 34 |
"JPY_LKR": {
|
| 35 |
"rate": 2.0491,
|
| 36 |
-
"timestamp":
|
| 37 |
},
|
| 38 |
"LKR_JPY": {
|
| 39 |
"rate": 0.4880191303499097,
|
| 40 |
-
"timestamp":
|
| 41 |
},
|
| 42 |
"AED_LKR": {
|
| 43 |
"rate": 88.6525,
|
| 44 |
-
"timestamp":
|
| 45 |
},
|
| 46 |
"LKR_AED": {
|
| 47 |
"rate": 0.011279997744000451,
|
| 48 |
-
"timestamp":
|
| 49 |
},
|
| 50 |
"SAR_LKR": {
|
| 51 |
"rate": 86.7697,
|
| 52 |
-
"timestamp":
|
| 53 |
},
|
| 54 |
"LKR_SAR": {
|
| 55 |
"rate": 0.011524760371419977,
|
| 56 |
-
"timestamp":
|
| 57 |
},
|
| 58 |
"INR_LKR": {
|
| 59 |
"rate": 3.3929,
|
| 60 |
-
"timestamp":
|
| 61 |
},
|
| 62 |
"LKR_INR": {
|
| 63 |
"rate": 0.2947331191606001,
|
| 64 |
-
"timestamp":
|
| 65 |
},
|
| 66 |
"CNY_LKR": {
|
| 67 |
"rate": 47.773,
|
| 68 |
-
"timestamp":
|
| 69 |
},
|
| 70 |
"LKR_CNY": {
|
| 71 |
"rate": 0.020932325790718607,
|
| 72 |
-
"timestamp":
|
| 73 |
},
|
| 74 |
"QAR_LKR": {
|
| 75 |
"rate": 89.3091,
|
| 76 |
-
"timestamp":
|
| 77 |
},
|
| 78 |
"LKR_QAR": {
|
| 79 |
"rate": 0.011197067264142175,
|
| 80 |
-
"timestamp":
|
| 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>© 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>© 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 |
+
|