From 020d1cc0edd9804279d8fc4cc1720a6c1bd6b285 Mon Sep 17 00:00:00 2001 From: Karan Kadam Date: Fri, 13 Mar 2026 17:44:51 +1030 Subject: [PATCH 1/3] feat: add toasts, email summary, dedup fix --- api/handlers/user.go | 10 ++-- bot/dealproc.go | 5 +- frontend/src/api/user.ts | 1 + frontend/src/components/Toast.tsx | 45 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 23 ++++++++-- frontend/src/types/index.ts | 1 + main.go | 76 +++++++++++++++++++++++++++++++ models/webuser.go | 11 +++-- persist/sqlite/webuser_db.go | 43 +++++++++++++++-- persist/types.go | 1 + util/email.go | 71 +++++++++++++++++++++++++++++ 11 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/Toast.tsx diff --git a/api/handlers/user.go b/api/handlers/user.go index 6ec7ca0..7646771 100644 --- a/api/handlers/user.go +++ b/api/handlers/user.go @@ -29,10 +29,11 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { } type preferencesRequest struct { - OzbGood bool `json:"ozb_good"` - OzbSuper bool `json:"ozb_super"` - AmzDaily bool `json:"amz_daily"` - AmzWeekly bool `json:"amz_weekly"` + OzbGood bool `json:"ozb_good"` + OzbSuper bool `json:"ozb_super"` + AmzDaily bool `json:"amz_daily"` + AmzWeekly bool `json:"amz_weekly"` + EmailSummary bool `json:"email_summary"` } // UpdatePreferences saves the user's deal notification toggles and syncs them @@ -61,6 +62,7 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { user.OzbSuper = req.OzbSuper user.AmzDaily = req.AmzDaily user.AmzWeekly = req.AmzWeekly + user.EmailSummary = req.EmailSummary if err := h.WebUserDB.UpdateWebUser(user); err != nil { h.Logger.Error("failed to save preferences", zap.Error(err)) diff --git a/bot/dealproc.go b/bot/dealproc.go index 4d61dbf..b71fd27 100644 --- a/bot/dealproc.go +++ b/bot/dealproc.go @@ -113,8 +113,9 @@ func (k *KramerBot) processOzbargainDeals() error { } } - if user.OzbSuper && deal.DealType == int(scrapers.OZB_SUPER) && !OzbDealSent(user, &deal) { - // User is subscribed to top deals (25+ votes within 24h). + if user.OzbSuper && !user.OzbGood && deal.DealType == int(scrapers.OZB_SUPER) && !OzbDealSent(user, &deal) { + // User is subscribed to top deals only (25+ votes within 24h). + // Skip if OzbGood is also on โ€” Regular is a superset so the deal is already sent. if err := k.SendOzbSuperDeal(user, &deal); err != nil { k.Logger.Error("Failed to send OZB top deal", zap.String("deal_id", deal.Id), diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 4f37abf..d0ad5a5 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -24,6 +24,7 @@ export async function updatePreferences(prefs: { ozb_super: boolean; amz_daily: boolean; amz_weekly: boolean; + email_summary: boolean; }): Promise { const res = await api.put>('/user/preferences', prefs); return res.data.data!; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..02eb9b3 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect, useRef } from 'react'; +import { CheckCircle } from 'lucide-react'; + +export function useToast() { + const [toastMsg, setToastMsg] = useState(''); + const timerRef = useRef | null>(null); + + const showToast = (msg: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + setToastMsg(msg); + timerRef.current = setTimeout(() => setToastMsg(''), 3000); + }; + + return { toastMsg, showToast }; +} + +export function Toast({ message }: { message: string }) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (message) { + setVisible(true); + } else { + setVisible(false); + } + }, [message]); + + if (!message) return null; + + return ( +
+ + + {message} + +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f200b5f..c950b07 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,6 +13,7 @@ import { import { OzbDealCard, AmazonDealCard } from '../components/DealCard'; import { TelegramLinker } from '../components/TelegramLinker'; import { Spinner } from '../components/Spinner'; +import { Toast, useToast } from '../components/Toast'; import type { WebUser } from '../types'; type Tab = 'ozb-good' | 'ozb-super' | 'amz-daily' | 'amz-weekly'; @@ -58,6 +59,7 @@ function ToggleRow({ export default function Dashboard({ user, onSignOut }: Props) { const qc = useQueryClient(); + const { toastMsg, showToast } = useToast(); const [tab, setTab] = useState('ozb-good'); const [newKeyword, setNewKeyword] = useState(''); const [showMobilePrefs, setShowMobilePrefs] = useState(false); @@ -102,25 +104,33 @@ export default function Dashboard({ user, onSignOut }: Props) { onSuccess: (updated) => { setNewKeyword(''); qc.setQueryData(['keywords'], updated); + showToast('Keyword added!'); }, }); const removeKw = useMutation({ mutationFn: removeKeyword, - onSuccess: (updated) => qc.setQueryData(['keywords'], updated), + onSuccess: (updated) => { + qc.setQueryData(['keywords'], updated); + showToast('Keyword removed!'); + }, }); // Preference mutation const prefsMutation = useMutation({ mutationFn: updatePreferences, - onSuccess: (updated) => qc.setQueryData(['profile'], updated), + onSuccess: (updated) => { + qc.setQueryData(['profile'], updated); + showToast('Preferences updated!'); + }, }); - const handlePrefToggle = (key: 'ozb_good' | 'ozb_super' | 'amz_daily' | 'amz_weekly', value: boolean) => { + const handlePrefToggle = (key: 'ozb_good' | 'ozb_super' | 'amz_daily' | 'amz_weekly' | 'email_summary', value: boolean) => { prefsMutation.mutate({ ozb_good: key === 'ozb_good' ? value : profile.ozb_good ?? false, ozb_super: key === 'ozb_super' ? value : profile.ozb_super ?? false, amz_daily: key === 'amz_daily' ? value : profile.amz_daily ?? false, amz_weekly: key === 'amz_weekly' ? value : profile.amz_weekly ?? false, + email_summary: key === 'email_summary' ? value : profile.email_summary ?? false, }); }; @@ -182,6 +192,12 @@ export default function Dashboard({ user, onSignOut }: Props) { onChange={(v) => handlePrefToggle('amz_weekly', v)} disabled={prefsMutation.isPending} /> + handlePrefToggle('email_summary', v)} + disabled={prefsMutation.isPending} + /> @@ -366,6 +382,7 @@ export default function Dashboard({ user, onSignOut }: Props) { )} + ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ad3fde..bdb0b41 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -8,6 +8,7 @@ export interface WebUser { ozb_super?: boolean; amz_daily?: boolean; amz_weekly?: boolean; + email_summary?: boolean; keywords?: string[]; created_at: string; updated_at: string; diff --git a/main.go b/main.go index b07cb42..dba592a 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,15 @@ import ( "context" "os" "os/signal" + "sort" + "strconv" "syscall" "time" "github.com/intothevoid/kramerbot/api" "github.com/intothevoid/kramerbot/bot" "github.com/intothevoid/kramerbot/models" + "github.com/intothevoid/kramerbot/persist" sqlite_persist "github.com/intothevoid/kramerbot/persist/sqlite" "github.com/intothevoid/kramerbot/pipup" "github.com/intothevoid/kramerbot/scrapers" @@ -105,6 +108,10 @@ func main() { } }() + if emailSvc.Enabled() && k.WebUserDB != nil { + startDailySummaryScheduler(logger, k.WebUserDB, ozbscraper, cccscraper, emailSvc) + } + // Graceful shutdown on SIGINT / SIGTERM โ€” exits the whole process. go func() { quit := make(chan os.Signal, 1) @@ -132,3 +139,72 @@ func main() { // Start the Telegram bot (blocks until process exits). k.StartBot() } + +func startDailySummaryScheduler( + logger *zap.Logger, + webUserDB persist.WebUserDBIF, + ozbScraper *scrapers.OzBargainScraper, + cccScraper *scrapers.CamCamCamScraper, + emailSvc *util.EmailService, +) { + go func() { + for { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, now.Location()) + if !now.Before(next) { + next = next.Add(24 * time.Hour) + } + logger.Info("Daily summary scheduled", zap.Time("next_run", next)) + time.Sleep(time.Until(next)) + sendDailySummaries(logger, webUserDB, ozbScraper, cccScraper, emailSvc) + } + }() +} + +func sendDailySummaries( + logger *zap.Logger, + webUserDB persist.WebUserDBIF, + ozbScraper *scrapers.OzBargainScraper, + cccScraper *scrapers.CamCamCamScraper, + emailSvc *util.EmailService, +) { + users, err := webUserDB.GetAllVerifiedWebUsers() + if err != nil { + logger.Error("daily summary: failed to fetch users", zap.Error(err)) + return + } + + // Collect OZB_SUPER deals, sorted by upvotes descending. + var ozbDeals []models.OzBargainDeal + for _, d := range ozbScraper.Deals { + if d.DealType == int(scrapers.OZB_SUPER) { + ozbDeals = append(ozbDeals, d) + } + } + sort.Slice(ozbDeals, func(i, j int) bool { + vi, _ := strconv.Atoi(ozbDeals[i].Upvotes) + vj, _ := strconv.Atoi(ozbDeals[j].Upvotes) + return vi > vj + }) + + // Collect AMZ_DAILY deals. + var amzDeals []models.CamCamCamDeal + for _, d := range cccScraper.Deals { + if d.DealType == int(scrapers.AMZ_DAILY) { + amzDeals = append(amzDeals, d) + } + } + + sent := 0 + for _, user := range users { + if !user.EmailSummary { + continue + } + if err := emailSvc.SendDailySummary(user.Email, ozbDeals, amzDeals); err != nil { + logger.Error("daily summary: send failed", zap.String("email", user.Email), zap.Error(err)) + } else { + sent++ + } + } + logger.Info("daily summary sent", zap.Int("recipients", sent)) +} diff --git a/models/webuser.go b/models/webuser.go index c7ec583..6c9f1d1 100644 --- a/models/webuser.go +++ b/models/webuser.go @@ -18,11 +18,12 @@ type WebUser struct { ResetToken *string `json:"-"` ResetTokenExpires *time.Time `json:"-"` // Deal notification preferences (synced to bot's UserData when Telegram is linked). - OzbGood bool `json:"ozb_good"` - OzbSuper bool `json:"ozb_super"` - AmzDaily bool `json:"amz_daily"` - AmzWeekly bool `json:"amz_weekly"` - Keywords []string `json:"keywords"` + OzbGood bool `json:"ozb_good"` + OzbSuper bool `json:"ozb_super"` + AmzDaily bool `json:"amz_daily"` + AmzWeekly bool `json:"amz_weekly"` + EmailSummary bool `json:"email_summary"` + Keywords []string `json:"keywords"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/persist/sqlite/webuser_db.go b/persist/sqlite/webuser_db.go index 107b4e1..3b27bff 100644 --- a/persist/sqlite/webuser_db.go +++ b/persist/sqlite/webuser_db.go @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS web_users ( ozb_super INTEGER NOT NULL DEFAULT 0, amz_daily INTEGER NOT NULL DEFAULT 0, amz_weekly INTEGER NOT NULL DEFAULT 0, + email_summary INTEGER NOT NULL DEFAULT 0, keywords TEXT NOT NULL DEFAULT '[]', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -50,6 +51,8 @@ var migrateStmts = []string{ `ALTER TABLE web_users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE web_users ADD COLUMN verify_token TEXT`, `ALTER TABLE web_users ADD COLUMN verify_token_expires DATETIME`, + // Daily email summary preference. + `ALTER TABLE web_users ADD COLUMN email_summary INTEGER NOT NULL DEFAULT 0`, // Indexes โ€” created after columns to avoid "no such column" on old schemas. `CREATE INDEX IF NOT EXISTS idx_web_users_email ON web_users(email)`, `CREATE INDEX IF NOT EXISTS idx_web_users_link_token ON web_users(link_token)`, @@ -77,7 +80,7 @@ const webUserColumns = ` telegram_chat_id, telegram_username, link_token, link_token_expires, reset_token, reset_token_expires, - ozb_good, ozb_super, amz_daily, amz_weekly, keywords, + ozb_good, ozb_super, amz_daily, amz_weekly, email_summary, keywords, created_at, updated_at` // CreateWebUser inserts a new web user record. @@ -168,6 +171,7 @@ func (udb *UserStoreDB) UpdateWebUser(user *models.WebUser) error { ozb_super = ?, amz_daily = ?, amz_weekly = ?, + email_summary = ?, keywords = ?, updated_at = ? WHERE id = ?`, @@ -176,7 +180,7 @@ func (udb *UserStoreDB) UpdateWebUser(user *models.WebUser) error { user.TelegramChatID, user.TelegramUsername, user.LinkToken, user.LinkTokenExpires, user.ResetToken, user.ResetTokenExpires, - user.OzbGood, user.OzbSuper, user.AmzDaily, user.AmzWeekly, string(kw), + user.OzbGood, user.OzbSuper, user.AmzDaily, user.AmzWeekly, user.EmailSummary, string(kw), user.UpdatedAt, user.ID, ) if err != nil { @@ -194,6 +198,39 @@ func (udb *UserStoreDB) DeleteWebUser(id string) error { return nil } +// GetAllVerifiedWebUsers returns all web users whose email has been verified. +func (udb *UserStoreDB) GetAllVerifiedWebUsers() ([]*models.WebUser, error) { + rows, err := udb.DB.Query(`SELECT ` + webUserColumns + ` FROM web_users WHERE email_verified = 1`) + if err != nil { + return nil, fmt.Errorf("failed to query verified web users: %w", err) + } + defer rows.Close() + + var users []*models.WebUser + for rows.Next() { + u := &models.WebUser{} + var kwJSON string + if err := rows.Scan( + &u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, + &u.EmailVerified, &u.VerifyToken, &u.VerifyTokenExpires, + &u.TelegramChatID, &u.TelegramUsername, + &u.LinkToken, &u.LinkTokenExpires, + &u.ResetToken, &u.ResetTokenExpires, + &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &u.EmailSummary, &kwJSON, + &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan web user: %w", err) + } + if kwJSON == "" || kwJSON == "null" { + u.Keywords = []string{} + } else { + json.Unmarshal([]byte(kwJSON), &u.Keywords) //nolint:errcheck + } + users = append(users, u) + } + return users, rows.Err() +} + // scanWebUser reads an explicit-column row into a WebUser struct. func (udb *UserStoreDB) scanWebUser(row *sql.Row) (*models.WebUser, error) { u := &models.WebUser{} @@ -204,7 +241,7 @@ func (udb *UserStoreDB) scanWebUser(row *sql.Row) (*models.WebUser, error) { &u.TelegramChatID, &u.TelegramUsername, &u.LinkToken, &u.LinkTokenExpires, &u.ResetToken, &u.ResetTokenExpires, - &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &kwJSON, + &u.OzbGood, &u.OzbSuper, &u.AmzDaily, &u.AmzWeekly, &u.EmailSummary, &kwJSON, &u.CreatedAt, &u.UpdatedAt, ) if err == sql.ErrNoRows { diff --git a/persist/types.go b/persist/types.go index f18464e..14dba59 100644 --- a/persist/types.go +++ b/persist/types.go @@ -28,4 +28,5 @@ type WebUserDBIF interface { GetWebUserByResetToken(token string) (*models.WebUser, error) UpdateWebUser(user *models.WebUser) error DeleteWebUser(id string) error + GetAllVerifiedWebUsers() ([]*models.WebUser, error) } diff --git a/util/email.go b/util/email.go index 467ca08..72cb22c 100644 --- a/util/email.go +++ b/util/email.go @@ -2,10 +2,13 @@ package util import ( "fmt" + "html" "log" "net/mail" "net/smtp" "strings" + + "github.com/intothevoid/kramerbot/models" ) // EmailService sends transactional emails via SMTP (STARTTLS, port 587). @@ -127,3 +130,71 @@ func (s *EmailService) SendPasswordResetEmail(to, resetLink string) error { `, resetLink, resetLink) return s.Send(to, subject, body) } + +// SendDailySummary sends the nightly deal digest email. +// ozbDeals should be pre-filtered to OZB_SUPER type, sorted by votes descending. +// amzDeals should be pre-filtered to AMZ_DAILY type. +func (s *EmailService) SendDailySummary(to string, ozbDeals []models.OzBargainDeal, amzDeals []models.CamCamCamDeal) error { + subject := "KramerBot Daily Deal Summary ๐Ÿ”ฅ" + + const limit = 10 + var ozbRows strings.Builder + for i, d := range ozbDeals { + if i >= limit { + break + } + ozbRows.WriteString(fmt.Sprintf(` + + + %s +
+ ๐Ÿ”บ %s votes + %s +
+ + `, html.EscapeString(d.Url), html.EscapeString(d.Title), html.EscapeString(d.Upvotes), html.EscapeString(d.PostedOn))) + } + if ozbRows.Len() == 0 { + ozbRows.WriteString(`No top deals today.`) + } + + var amzRows strings.Builder + for i, d := range amzDeals { + if i >= limit { + break + } + amzRows.WriteString(fmt.Sprintf(` + + + %s +
+ ๐Ÿ“ฆ Amazon Daily +
+ + `, html.EscapeString(d.Url), html.EscapeString(d.Title))) + } + if amzRows.Len() == 0 { + amzRows.WriteString(`No Amazon daily deals today.`) + } + + body := fmt.Sprintf(` + + +
+ KramerBot โ€” Daily Summary +
+
+

๐Ÿ”ฅ Top OzBargain Deals

+ %s
+

๐Ÿ“ฆ Amazon Daily Deals

+ %s
+
+

+ KramerBot Daily Summary ยท Manage your preferences in the Dashboard +

+
+ +`, ozbRows.String(), amzRows.String()) + + return s.Send(to, subject, body) +} From a41b3673b45291b7a5c841c729faea07304a421a Mon Sep 17 00:00:00 2001 From: Karan Kadam Date: Sun, 15 Mar 2026 21:23:22 +1030 Subject: [PATCH 2/3] refactor: add timezone support to email summaries --- README.md | 13 ++++++++++--- main.go | 13 ++++++++++--- util/config.go | 15 ++++++++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 38611b3..472d007 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ A Telegram bot โ€” and now a full web app โ€” to get you the latest deals from h 6. Keep track of deals already sent to avoid duplicate notifications 7. Supports scraping www.ozbargain.com.au โ€” Regular (all deals) and Top (25+ votes in 24h) deals 8. Supports scraping www.amazon.com.au (via Camel Camel Camel RSS) โ€” Top daily and weekly deals -9. Supports Android TV notifications (via Pipup) -10. Admin announcement broadcast +9. **Daily email summary** โ€” opt-in digest of top OzBargain + Amazon Daily deals sent at 8pm (configurable timezone, defaults to `Australia/Adelaide`) +10. Supports Android TV notifications (via Pipup) +11. Admin announcement broadcast ## Web UI @@ -64,12 +65,13 @@ The web interface runs at `http://localhost:8989` (or the configured port). ## Email (SMTP) -Email is used for two flows: +Email is used for three flows: | Flow | Trigger | Link destination | |---|---|---| | Email verification | New account registration | `/verify-email?token=โ€ฆ` | | Password reset | Forgot password form | `/reset-password?token=โ€ฆ` | +| Daily summary | 8pm scheduler (opt-in per user) | โ€” | ### Configuring an SMTP provider @@ -93,6 +95,8 @@ Recommended providers: | [Mailjet](https://mailjet.com) | 6,000/month | Requires verified sender domain or address | | [SendGrid](https://sendgrid.com) | 100/day | `SMTP_USER=apikey`, `SMTP_PASS=` | +Set `SUMMARY_TIMEZONE` to any valid [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g. `Australia/Sydney`, `America/New_York`). The server timezone is irrelevant โ€” the scheduler always targets 8pm in the configured zone. + Also update `api.web_url` in `config.yaml` to your public domain so links in emails point to the right place: ```yaml @@ -164,6 +168,9 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=KramerBot + +# Daily summary timezone โ€” IANA timezone name (default: Australia/Adelaide) +SUMMARY_TIMEZONE=Australia/Adelaide ``` Generate a JWT secret: diff --git a/main.go b/main.go index dba592a..417051d 100644 --- a/main.go +++ b/main.go @@ -109,7 +109,13 @@ func main() { }() if emailSvc.Enabled() && k.WebUserDB != nil { - startDailySummaryScheduler(logger, k.WebUserDB, ozbscraper, cccscraper, emailSvc) + loc, err := time.LoadLocation(config.API.SummaryTimezone) + if err != nil { + logger.Warn("Invalid SUMMARY_TIMEZONE, falling back to UTC", + zap.String("timezone", config.API.SummaryTimezone), zap.Error(err)) + loc = time.UTC + } + startDailySummaryScheduler(logger, k.WebUserDB, ozbscraper, cccscraper, emailSvc, loc) } // Graceful shutdown on SIGINT / SIGTERM โ€” exits the whole process. @@ -146,11 +152,12 @@ func startDailySummaryScheduler( ozbScraper *scrapers.OzBargainScraper, cccScraper *scrapers.CamCamCamScraper, emailSvc *util.EmailService, + loc *time.Location, ) { go func() { for { - now := time.Now() - next := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, now.Location()) + now := time.Now().In(loc) + next := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, loc) if !now.Before(next) { next = next.Add(24 * time.Hour) } diff --git a/util/config.go b/util/config.go index 01fe708..1a69a4d 100644 --- a/util/config.go +++ b/util/config.go @@ -32,11 +32,12 @@ type SMTPConfig struct { // APIConfig holds configuration for the HTTP API server. type APIConfig struct { - Enabled bool `mapstructure:"enabled"` - Port int `mapstructure:"port"` - WebURL string `mapstructure:"web_url"` - CORSOrigins []string `mapstructure:"cors_origins"` - JWTExpiryHours int `mapstructure:"jwt_expiry_hours"` + Enabled bool `mapstructure:"enabled"` + Port int `mapstructure:"port"` + WebURL string `mapstructure:"web_url"` + CORSOrigins []string `mapstructure:"cors_origins"` + JWTExpiryHours int `mapstructure:"jwt_expiry_hours"` + SummaryTimezone string `mapstructure:"summary_timezone"` } // SQLiteConfig holds SQLite database configuration @@ -243,6 +244,7 @@ func SetupConfig(confPath string, logger *zap.Logger) (*Config, error) { v.SetDefault("api.web_url", config.API.WebURL) v.SetDefault("api.cors_origins", config.API.CORSOrigins) v.SetDefault("api.jwt_expiry_hours", config.API.JWTExpiryHours) + v.SetDefault("api.summary_timezone", "Australia/Adelaide") v.SetDefault("smtp.host", config.SMTP.Host) v.SetDefault("smtp.port", config.SMTP.Port) v.SetDefault("smtp.username", config.SMTP.Username) @@ -295,6 +297,9 @@ func SetupConfig(confPath string, logger *zap.Logger) (*Config, error) { if v := os.Getenv("SMTP_FROM"); v != "" { config.SMTP.From = v } + if v := os.Getenv("SUMMARY_TIMEZONE"); v != "" { + config.API.SummaryTimezone = v + } // Ensure database directory exists dbDir := filepath.Dir(config.SQLite.DBPath) From 7fe3f6649803f600f0a347deb93a5f6a8ee8f19f Mon Sep 17 00:00:00 2001 From: Karan Kadam Date: Mon, 16 Mar 2026 21:51:41 +1030 Subject: [PATCH 3/3] fix: linter errors --- frontend/src/components/Toast.tsx | 32 ++----------------------------- frontend/src/hooks/useToast.ts | 14 ++++++++++++++ frontend/src/pages/Dashboard.tsx | 3 ++- 3 files changed, 18 insertions(+), 31 deletions(-) create mode 100644 frontend/src/hooks/useToast.ts diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 02eb9b3..eafa63e 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -1,40 +1,12 @@ -import { useState, useEffect, useRef } from 'react'; import { CheckCircle } from 'lucide-react'; -export function useToast() { - const [toastMsg, setToastMsg] = useState(''); - const timerRef = useRef | null>(null); - - const showToast = (msg: string) => { - if (timerRef.current) clearTimeout(timerRef.current); - setToastMsg(msg); - timerRef.current = setTimeout(() => setToastMsg(''), 3000); - }; - - return { toastMsg, showToast }; -} - export function Toast({ message }: { message: string }) { - const [visible, setVisible] = useState(false); - - useEffect(() => { - if (message) { - setVisible(true); - } else { - setVisible(false); - } - }, [message]); - if (!message) return null; return (
diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..9669b6f --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,14 @@ +import { useState, useRef } from 'react'; + +export function useToast() { + const [toastMsg, setToastMsg] = useState(''); + const timerRef = useRef | null>(null); + + const showToast = (msg: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + setToastMsg(msg); + timerRef.current = setTimeout(() => setToastMsg(''), 3000); + }; + + return { toastMsg, showToast }; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c950b07..2129adb 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,7 +13,8 @@ import { import { OzbDealCard, AmazonDealCard } from '../components/DealCard'; import { TelegramLinker } from '../components/TelegramLinker'; import { Spinner } from '../components/Spinner'; -import { Toast, useToast } from '../components/Toast'; +import { Toast } from '../components/Toast'; +import { useToast } from '../hooks/useToast'; import type { WebUser } from '../types'; type Tab = 'ozb-good' | 'ozb-super' | 'amz-daily' | 'amz-weekly';