From 2bd5ff6615379698c2b5fec3cd95147bff11baf8 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 12:42:15 -0400 Subject: [PATCH 1/6] refactor: stores as global constant --- Main.hs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Main.hs b/Main.hs index eee1cd7..be9ce64 100644 --- a/Main.hs +++ b/Main.hs @@ -35,6 +35,14 @@ help = printlog :: String -> IO () printlog = hPutStrLn stderr +stores :: [(String, String)] +stores = + [ ("701", "Chicago South Loop") + , ("31", "Los Angeles") + , ("546", "NYC East Village") + , ("452", "Austin Seaholm") + ] + handleArgs :: [String] -> IO () handleArgs ["gen"] = do conn <- openDB @@ -48,12 +56,6 @@ handleArgs ["gen"] = do L.writeFile "site/index.html" html handleArgs ["fetch"] = do conn <- openDB - let stores = - [ "701" -- Chicago South Loop - , "31" -- Los Angeles - , "546" -- NYC East Village - , "452" -- Austin Seaholm - ] printlog $ "fetching stores: " <> show stores SQL.withTransaction conn $ mapConcurrently_ (scrapeStore conn) stores @@ -64,10 +66,10 @@ handleArgs ["fetch"] = do handleArgs _ = printlog help >> exitFailure -- | Fetch all items for the store and insert into the given database. -scrapeStore :: SQL.Connection -> String -> IO () +scrapeStore :: SQL.Connection -> (String, String) -> IO () scrapeStore conn store = do - items <- allItemsByStore store - mapM_ (insert conn store) items + items <- allItemsByStore (fst store) + mapM_ (insert conn (fst store)) items -- | Generate the home page html body. pageBody :: [PriceChange] -> [DBItem] -> String -> H.Html From db9406ea1049b5462a74d772a2f61ab039aa5719 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 13:40:50 -0400 Subject: [PATCH 2/6] feat: add store selector --- Main.hs | 15 ++++++++++++++- script.js | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 script.js diff --git a/Main.hs b/Main.hs index be9ce64..ea0b768 100644 --- a/Main.hs +++ b/Main.hs @@ -82,8 +82,12 @@ pageBody changes items timestamp = do H.br H.br H.strong ! A.style "font-size: 1.15em; font-family: serif" $ do - H.i "Disclaimer: This website is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Trader Joe's, or any of its subsidiaries or its affiliates. All prices are sourced from Trader Joe's South Loop in Chicago, IL (store code 701). There may be regional price differences from those listed on this site. This website may include discontinued or unavailable products." + H.i "Disclaimer: This website is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Trader Joe's, or any of its subsidiaries or its affiliates. There may be regional price differences from those listed on this site. This website may include discontinued or unavailable products." H.br + H.label ! A.for "store-select" $ do + "Select store" + H.select ! A.class_ "dropdown" ! A.name "store" ! A.id "store-select" $ do + H.toMarkup $ displayStoreItemOption <$> stores H.h1 "(Unofficial) Trader Joe's Price Tracking" H.form ! A.action "signup" ! A.class_ "signup-form" ! A.role "form" $ do H.label ! A.for "email" $ "Sign up for a weekly email of price changes." @@ -106,8 +110,12 @@ renderPage page = renderHtml $ H.html $ do H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1.0" H.meta ! A.name "description" ! A.content "Daily Tracking of Trader Joe's Price Changes" H.meta ! A.name "keywords" ! A.content "trader joes, prices, price tracking" + H.script ! A.src "https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js" $ "" + H.link ! A.href "https://cdn.jsdelivr.net/npm/select2@4/dist/css/select2.min.css" ! A.rel "stylesheet" + H.script ! A.src "https://cdn.jsdelivr.net/npm/select2@4/dist/js/select2.min.js" $ "" H.title "Trader Joe's Prices" H.body $ do + H.script $(embedStringFile "./script.js") H.style $(embedStringFile "./style.css") H.toMarkup page @@ -132,6 +140,11 @@ priceChangeClass (before, after) = fromMaybe "" $ do afterNum <- readMaybe after :: Maybe Float pure $ if beforeNum > afterNum then "green" else "red" +-- | Display store as a dropdown option. +displayStoreItemOption :: (String, String) -> H.Html +displayStoreItemOption (store_id, name) = + H.option ! A.value (H.toValue store_id) $ H.toHtml (name <> " (" <> store_id <> ")") + -- | URL to the product detail page by `sku`. productUrl :: String -> H.AttributeValue productUrl sku = H.toValue $ "https://traderjoes.com/home/products/pdp/" ++ sku diff --git a/script.js b/script.js new file mode 100644 index 0000000..08ba905 --- /dev/null +++ b/script.js @@ -0,0 +1,3 @@ +$(document).ready(() => { + $('.dropdown').select2({ width: '70%' }); +}); \ No newline at end of file From f94b54e680b899e264cb13f0cb7a485d869fd2a2 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 13:49:23 -0400 Subject: [PATCH 3/6] refactor: create price change json, move price change list generation to frontend --- Main.hs | 50 ++++++++++++++++--------------------------- script.js | 32 +++++++++++++++++++++++++++ sql/price-changes.sql | 44 +++++++++++++++++++++++-------------- 3 files changed, 79 insertions(+), 47 deletions(-) diff --git a/Main.hs b/Main.hs index ea0b768..860a24c 100644 --- a/Main.hs +++ b/Main.hs @@ -2,10 +2,10 @@ module Main where import Control.Concurrent.Async import Control.Monad +import Data.Aeson ( encodeFile, ToJSON, toEncoding, defaultOptions, genericToEncoding ) import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy qualified as L import Data.FileEmbed (embedStringFile) -import Data.Maybe import Data.Time (defaultTimeLocale, formatTime, getCurrentTimeZone, utcToLocalTime) import Data.Time.Clock.POSIX import Database.SQLite.Simple qualified as SQL @@ -20,7 +20,6 @@ import Text.Blaze.Html5 ((!)) import Text.Blaze.Html5 qualified as H import Text.Blaze.Html5.Attributes qualified as A import Text.Blaze.Internal qualified as A -import Text.Read (readMaybe) main :: IO () main = getArgs >>= handleArgs @@ -46,13 +45,13 @@ stores = handleArgs :: [String] -> IO () handleArgs ["gen"] = do conn <- openDB - changes <- priceChanges conn + setupCleanDirectory "site" + printlog "writing to ./site" + mapConcurrently_ (priceChangesJson conn) stores allitems <- latestPrices conn SQL.close conn ts <- showTime - let html = renderPage $ pageBody changes allitems ts - setupCleanDirectory "site" - printlog "writing to ./site" + let html = renderPage $ pageBody allitems ts L.writeFile "site/index.html" html handleArgs ["fetch"] = do conn <- openDB @@ -72,8 +71,8 @@ scrapeStore conn store = do mapM_ (insert conn (fst store)) items -- | Generate the home page html body. -pageBody :: [PriceChange] -> [DBItem] -> String -> H.Html -pageBody changes items timestamp = do +pageBody :: [DBItem] -> String -> H.Html +pageBody items timestamp = do H.i . H.toMarkup $ "Last updated: " ++ timestamp H.br H.a ! A.class_ "underline" ! A.href "https://github.com/cmoog/traderjoes" ! A.target "_blank" $ "Source code" @@ -94,9 +93,8 @@ pageBody changes items timestamp = do H.input ! A.required "" ! A.name "email" ! A.type_ "email" ! A.class_ "formInput input-lg" ! A.placeholder "example@gmail.com" H.button ! A.type_ "submit" ! A.class_ "btn primary" ! A.title "Email address" $ "Sign Up" H.h2 "Price Changes" - H.table ! A.class_ "table table-striped table-gray" $ do + H.table ! A.class_ "table table-striped table-gray" ! A.id "price-changes" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Date Changed", "Item Name", "Old Price", "New Price"] - H.tbody . H.toMarkup $ displayPriceChange <$> changes H.h2 "All Items" H.table ! A.class_ "table table-striped table-gray" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Item Name", "Retail Price"] @@ -125,20 +123,6 @@ displayDBItem (DBItem{ditem_title, dretail_price, dsku}) = H.tr $ do H.td $ H.a ! A.href (productUrl dsku) ! A.target "_blank" $ H.toHtml ditem_title H.td $ H.toHtml dretail_price --- | Display the price change as a table row. -displayPriceChange :: PriceChange -> H.Html -displayPriceChange (PriceChange{pitem_title, pbefore_price, pafter_price, pafter_date, psku}) = H.tr $ do - H.td $ H.toHtml pafter_date - H.td $ H.a ! A.href (productUrl psku) ! A.target "_blank" $ H.toHtml pitem_title - H.td $ H.toHtml pbefore_price - H.td ! A.class_ (H.toValue $ priceChangeClass (pbefore_price, pafter_price)) $ H.toHtml pafter_price - --- | Color the price change table cell based on whether the price increased or decreased. -priceChangeClass :: (String, String) -> String -priceChangeClass (before, after) = fromMaybe "" $ do - beforeNum <- readMaybe before :: Maybe Float - afterNum <- readMaybe after :: Maybe Float - pure $ if beforeNum > afterNum then "green" else "red" -- | Display store as a dropdown option. displayStoreItemOption :: (String, String) -> H.Html @@ -169,19 +153,23 @@ latestPrices conn = SQL.query_ conn $(embedStringFile "./sql/latest-prices.sql") data PriceChange = PriceChange { psku :: String , pitem_title :: String - , pbefore_price :: String - , pafter_price :: String - , pbefore_date :: String - , pafter_date :: String + , pbefore_price :: Maybe String + , pafter_price :: Maybe String + , pbefore_date :: Maybe String + , pafter_date :: Maybe String , pstore_code :: String } deriving (Generic, Show) instance SQL.FromRow PriceChange --- | Each change in item `retail_price` partitioned by `sku` and `store_code`. -priceChanges :: SQL.Connection -> IO [PriceChange] -priceChanges conn = SQL.query_ conn $(embedStringFile "./sql/price-changes.sql") +instance ToJSON PriceChange where + toEncoding = genericToEncoding defaultOptions + +priceChangesJson :: SQL.Connection -> (String, String) -> IO () +priceChangesJson conn store = do + changes <- SQL.query conn $(embedStringFile "./sql/price-changes.sql") [fst store] :: IO[PriceChange] + encodeFile ("site/prices-" <> fst store <> ".json") changes openDB :: IO SQL.Connection openDB = do diff --git a/script.js b/script.js index 08ba905..4d3a190 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,35 @@ $(document).ready(() => { + let dateFormatter = t => { + let d = t.split(" ")[0], m = parseInt(d.split("-")[1]) * 3 - 1; + return `${"--JanFebMarAprMayJunJulAugSepOctNovDec".substring(m, m + 3)} ${d.split("-")[2]}, ${d.split("-")[0]}` + } + let storeHandler = _ => { + let storeId = $('#store-select').select2('data')[0].id; + $('.price-changes').remove(); + fetch(`./prices-${storeId}.json`) + .then(r => { return r.json(); }) + .then(r => { + for (let i = 0; i < r.length; i++) { + let d = r[i]; + if (d.pafter_date != null) { + $('').addClass('price-changes').append( + $('').text(dateFormatter(d.pafter_date)), + $('').append( + $('') + .attr('href', `https://traderjoes.com/home/products/pdp/${d.psku}`) + .attr('target', '_blank') + .text(d.pitem_title) + ), + $('').text(d.pbefore_price), + $('') + .addClass(d.pbefore_price > d.pafter_price ? 'green' : 'red') + .text(d.pafter_price) + ).appendTo('#price-changes'); + } + } + }); + }; $('.dropdown').select2({ width: '70%' }); + $('#store-select').on('select2:select', storeHandler); + storeHandler(); }); \ No newline at end of file diff --git a/sql/price-changes.sql b/sql/price-changes.sql index 8dd7969..740d08a 100644 --- a/sql/price-changes.sql +++ b/sql/price-changes.sql @@ -22,30 +22,42 @@ WITH store_code FROM items + ), + EarliestPrices AS ( + SELECT + sku, + NULL AS retail_price, + item_title, + retail_price AS next_price, + NULL AS inserted_at, + inserted_at AS next_inserted_at, + store_code + FROM + items + GROUP BY sku, store_code + HAVING inserted_at = min(inserted_at) + ), + Result AS ( + SELECT * FROM EarliestPrices + UNION + SELECT * FROM PriceChanges + WHERE + retail_price IS NOT next_price + AND retail_price != "0.01" + AND next_price IS NOT "0.01" ) SELECT sku, item_title, retail_price AS before_price, next_price AS after_price, - substr ( - "--JanFebMarAprMayJunJulAugSepOctNovDec", - strftime ("%m", inserted_at) * 3, - 3 - ) || strftime (' %d, %Y', inserted_at) AS before_date, - substr ( - "--JanFebMarAprMayJunJulAugSepOctNovDec", - strftime ("%m", next_inserted_at) * 3, - 3 - ) || strftime (' %d, %Y', next_inserted_at) AS after_date, + inserted_at AS before_date, + next_inserted_at AS after_date, store_code FROM - PriceChanges + Result WHERE - retail_price != next_price - AND next_price IS NOT NULL - AND retail_price != "0.01" - AND next_price != "0.01" - AND store_code = "701" + store_code = ? + AND before_date IS NOT NULL ORDER BY next_inserted_at DESC; From a1030f25c8cee53f98208291690e2afec140c283 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 13:52:24 -0400 Subject: [PATCH 4/6] refactor: move current price list to frontend --- Main.hs | 10 +--------- script.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Main.hs b/Main.hs index 860a24c..0b2c24f 100644 --- a/Main.hs +++ b/Main.hs @@ -96,9 +96,8 @@ pageBody items timestamp = do H.table ! A.class_ "table table-striped table-gray" ! A.id "price-changes" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Date Changed", "Item Name", "Old Price", "New Price"] H.h2 "All Items" - H.table ! A.class_ "table table-striped table-gray" $ do + H.table ! A.class_ "table table-striped table-gray" ! A.id "current-prices" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Item Name", "Retail Price"] - H.tbody . H.toMarkup $ displayDBItem <$> items -- | Render the given page body with html head/styles/meta. renderPage :: (H.ToMarkup a) => a -> ByteString @@ -117,13 +116,6 @@ renderPage page = renderHtml $ H.html $ do H.style $(embedStringFile "./style.css") H.toMarkup page --- | Display item as a table row. -displayDBItem :: DBItem -> H.Html -displayDBItem (DBItem{ditem_title, dretail_price, dsku}) = H.tr $ do - H.td $ H.a ! A.href (productUrl dsku) ! A.target "_blank" $ H.toHtml ditem_title - H.td $ H.toHtml dretail_price - - -- | Display store as a dropdown option. displayStoreItemOption :: (String, String) -> H.Html displayStoreItemOption (store_id, name) = diff --git a/script.js b/script.js index 4d3a190..cc33668 100644 --- a/script.js +++ b/script.js @@ -6,6 +6,7 @@ $(document).ready(() => { let storeHandler = _ => { let storeId = $('#store-select').select2('data')[0].id; $('.price-changes').remove(); + $('.current-prices').remove(); fetch(`./prices-${storeId}.json`) .then(r => { return r.json(); }) .then(r => { @@ -26,6 +27,17 @@ $(document).ready(() => { .text(d.pafter_price) ).appendTo('#price-changes'); } + else if (d.pafter_date == null) { + $('').addClass('current-prices').append( + $('').append( + $('') + .attr('href', `https://traderjoes.com/home/products/pdp/${d.psku}`) + .attr('target', '_blank') + .text(d.pitem_title) + ), + $('').text(d.pbefore_price) + ).appendTo('#current-prices'); + } } }); }; From d865d187ad4dc495f4dae5743184d0123d264560 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 13:54:44 -0400 Subject: [PATCH 5/6] feat: add item dropdown --- Main.hs | 10 ++++++++++ style.css | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/Main.hs b/Main.hs index 0b2c24f..e8e193e 100644 --- a/Main.hs +++ b/Main.hs @@ -92,6 +92,11 @@ pageBody items timestamp = do H.label ! A.for "email" $ "Sign up for a weekly email of price changes." H.input ! A.required "" ! A.name "email" ! A.type_ "email" ! A.class_ "formInput input-lg" ! A.placeholder "example@gmail.com" H.button ! A.type_ "submit" ! A.class_ "btn primary" ! A.title "Email address" $ "Sign Up" + H.h2 "Price Chart" + H.label ! A.for "item-select" $ do + "Select item(s)" + H.select ! A.class_ "dropdown" ! A.name "items[]" ! A.id "item-select" ! A.multiple "multiple" $ do + H.toMarkup $ displayDBItemOption <$> items H.h2 "Price Changes" H.table ! A.class_ "table table-striped table-gray" ! A.id "price-changes" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Date Changed", "Item Name", "Old Price", "New Price"] @@ -116,6 +121,11 @@ renderPage page = renderHtml $ H.html $ do H.style $(embedStringFile "./style.css") H.toMarkup page +-- | Display item as a dropdown option. +displayDBItemOption :: DBItem -> H.Html +displayDBItemOption (DBItem{ditem_title, dsku}) = + H.option ! A.value (H.toValue dsku) $ H.toHtml (ditem_title <> " (" <> dsku <> ")") + -- | Display store as a dropdown option. displayStoreItemOption :: (String, String) -> H.Html displayStoreItemOption (store_id, name) = diff --git a/style.css b/style.css index 79095e5..87e12c4 100644 --- a/style.css +++ b/style.css @@ -196,3 +196,8 @@ td.red { flex-direction: column; } } + +.select2-container { + margin-left: 4px; + margin-right: 4px; +} \ No newline at end of file From af3aa0d52e15c941a0bc3d892b786e1e6f38ac62 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 28 May 2025 16:33:47 -0400 Subject: [PATCH 6/6] feat: add chart --- Main.hs | 4 ++++ script.js | 51 ++++++++++++++++++++++++++++++++++++++++++- sql/price-changes.sql | 1 - 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Main.hs b/Main.hs index e8e193e..285aefb 100644 --- a/Main.hs +++ b/Main.hs @@ -97,6 +97,8 @@ pageBody items timestamp = do "Select item(s)" H.select ! A.class_ "dropdown" ! A.name "items[]" ! A.id "item-select" ! A.multiple "multiple" $ do H.toMarkup $ displayDBItemOption <$> items + H.div ! A.class_ "chart-container" $ do + H.canvas ! A.id "chart" $ "" H.h2 "Price Changes" H.table ! A.class_ "table table-striped table-gray" ! A.id "price-changes" $ do H.thead . H.tr . H.toMarkup $ H.th <$> ["Date Changed", "Item Name", "Old Price", "New Price"] @@ -115,6 +117,8 @@ renderPage page = renderHtml $ H.html $ do H.script ! A.src "https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js" $ "" H.link ! A.href "https://cdn.jsdelivr.net/npm/select2@4/dist/css/select2.min.css" ! A.rel "stylesheet" H.script ! A.src "https://cdn.jsdelivr.net/npm/select2@4/dist/js/select2.min.js" $ "" + H.script ! A.src "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" $ "" + H.script ! A.src "https://cdn.jsdelivr.net/npm/chartjs-adapter-intl@0.1/dist/chartjs-adapter-intl.umd.min.js" $ "" H.title "Trader Joe's Prices" H.body $ do H.script $(embedStringFile "./script.js") diff --git a/script.js b/script.js index cc33668..83c1851 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,5 @@ +let data = [], chart = null; + $(document).ready(() => { let dateFormatter = t => { let d = t.split(" ")[0], m = parseInt(d.split("-")[1]) * 3 - 1; @@ -10,9 +12,10 @@ $(document).ready(() => { fetch(`./prices-${storeId}.json`) .then(r => { return r.json(); }) .then(r => { + data = r; for (let i = 0; i < r.length; i++) { let d = r[i]; - if (d.pafter_date != null) { + if (d.pbefore_date != null && d.pafter_date != null) { $('').addClass('price-changes').append( $('').text(dateFormatter(d.pafter_date)), $('').append( @@ -39,9 +42,55 @@ $(document).ready(() => { ).appendTo('#current-prices'); } } + itemHandler(); }); }; + let itemHandler = _ => { + let items = $('#item-select').select2('data').map(d => ({sku: d.id, name: d.text})); + if (items.length == 0) return; + let ids = items.map(d => d.sku), + subset = data.filter(d => ids.indexOf(d.psku) > -1), + datasets = items.map(d => ({ + label: d.name, + data: subset.filter(p => p.psku == d.sku && p.pafter_date != null).map(p => ({x: p.pafter_date, y: p.pafter_price})) + })); + console.log(datasets) + if (chart != null) chart.destroy(); + chart = new Chart( + document.getElementById('chart'), + { + type: 'line', + data: { + datasets + }, + options: { + responsive: true, + plugins: { + legend: { + position: 'top', + } + }, + scales: { + x: { + type: 'time', + time: { + unit: 'day' + }, + }, + y: { + title: { + display: true, + text: 'Price' + } + } + } + }, + } + ); + }; $('.dropdown').select2({ width: '70%' }); $('#store-select').on('select2:select', storeHandler); + $('#item-select').on('select2:select', itemHandler); + $('#item-select').on('select2:unselect', itemHandler); storeHandler(); }); \ No newline at end of file diff --git a/sql/price-changes.sql b/sql/price-changes.sql index 740d08a..d226c32 100644 --- a/sql/price-changes.sql +++ b/sql/price-changes.sql @@ -58,6 +58,5 @@ FROM Result WHERE store_code = ? - AND before_date IS NOT NULL ORDER BY next_inserted_at DESC;