diff --git a/Main.hs b/Main.hs index eee1cd7..285aefb 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 @@ -35,25 +34,27 @@ 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 - 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 - 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,14 +65,14 @@ 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 -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" @@ -80,21 +81,30 @@ 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." 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.div ! A.class_ "chart-container" $ do + H.canvas ! A.id "chart" $ "" 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.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 @@ -104,31 +114,26 @@ 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.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") 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 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 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) = + 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 @@ -154,19 +159,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 new file mode 100644 index 0000000..83c1851 --- /dev/null +++ b/script.js @@ -0,0 +1,96 @@ +let data = [], chart = null; + +$(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(); + $('.current-prices').remove(); + 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.pbefore_date != null && 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'); + } + 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'); + } + } + 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 8dd7969..d226c32 100644 --- a/sql/price-changes.sql +++ b/sql/price-changes.sql @@ -22,30 +22,41 @@ 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 = ? ORDER BY next_inserted_at DESC; 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