Skip to content

Store selection, add chart & move list to frontend #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 58 additions & 49 deletions Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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 "[email protected]"
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
Expand All @@ -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/[email protected]/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
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions script.js
Original file line number Diff line number Diff line change
@@ -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) {
$('<tr>').addClass('price-changes').append(
$('<td>').text(dateFormatter(d.pafter_date)),
$('<td>').append(
$('<a>')
.attr('href', `https://traderjoes.com/home/products/pdp/${d.psku}`)
.attr('target', '_blank')
.text(d.pitem_title)
),
$('<td>').text(d.pbefore_price),
$('<td>')
.addClass(d.pbefore_price > d.pafter_price ? 'green' : 'red')
.text(d.pafter_price)
).appendTo('#price-changes');
}
else if (d.pafter_date == null) {
$('<tr>').addClass('current-prices').append(
$('<td>').append(
$('<a>')
.attr('href', `https://traderjoes.com/home/products/pdp/${d.psku}`)
.attr('target', '_blank')
.text(d.pitem_title)
),
$('<td>').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();
});
43 changes: 27 additions & 16 deletions sql/price-changes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,8 @@ td.red {
flex-direction: column;
}
}

.select2-container {
margin-left: 4px;
margin-right: 4px;
}