[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [cotterjd]\r\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ \"main\", **/** ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ \"main\" ]\n  schedule:\n    - cron: '37 1 * * 0'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}\n    timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'javascript' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ]\n        # Use only 'java' to analyze code written in Java, Kotlin or both\n        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v3\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v2\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n        # queries: security-extended,security-and-quality\n\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v2\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n    #   If the Autobuild fails above, remove it and uncomment the following three lines.\n    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n    # - run: |\n    #     echo \"Run, Build Application using script\"\n    #     ./location_of_script_within_repo/buildscript.sh\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v2\n      with:\n        category: \"/language:${{matrix.language}}\"\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Run Tests\n\non:\n  push:\n    branches:\n      - \"*\"\n  pull_request:\n    branches:\n      - \"*\"\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [18.x]\n        directory: [\"./server\", \"./client\"]\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: \"npm\"\n          cache-dependency-path: ${{ matrix.directory }}/package-lock.json\n\n      - name: Install dependencies\n        run: |\n          cd ${{ matrix.directory }}\n          npm ci\n\n      - name: Build project\n        run: |\n          cd ${{ matrix.directory }}\n          npm run build --if-present\n\n      - name: Run tests\n        run: |\n          cd ${{ matrix.directory }}\n          npm run test\n"
  },
  {
    "path": ".gitignore",
    "content": ".vscode\n.DS_Store\n**/.DS_Store\n\n# client\n\n## dependencies\nclient/node_modules\nclient/.pnp\nclient/.pnp.js\n\n## testing\nclient/coverage\n\n\n## production\nclient/build\nclient/dist\n\n## misc\nclient/.DS_Store\nclient/.env\nclient/.env.local\nclient/.env.development.local\nclient/.env.test.local\nclient/.env.production.local\n\nclient/npm-debug.log*\nclient/yarn-debug.log*\nclient/yarn-error.log*\n\n\n# server\n\nserver/.env\nserver/node_modules\nserver/coverage"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 95,\n  \"tabWidth\": 2,\n  \"bracketSameLine\": true\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 HenB13\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\" text-align=\"center\">\n\n# JRE MISSING <img height=\"23px\" width=\"23px\" padding=\"5px\" src=\"./client/public/favicon.png\" alt=\"JRE MISSING LOGO\" />\n\n<p>\n  <img alt=\"Repository size\" src=\"https://img.shields.io/github/repo-size/HenB13/jre-missing?color=#ee7d2c\">\n  <img alt=\"Repository license\" src=\"https://img.shields.io/github/license/HenB13/jre-missing?color=#ee7d2c\">\n  <img alt=\"Repository license\" src=\"https://img.shields.io/static/v1?label=sauna+temp&message=200F&color=#ee7d2c\">\n  \n</p>\n\nAutomatically detects and lists episodes of [The Joe Rogan Experience](https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk) podcast that are currently not available on the Spotify platform. Also detects if episodes have been shortened in duration.\n\n## As Mentioned In 🗞️\n\n<a href=\"https://www.washingtonpost.com/arts-entertainment/2022/02/06/spotify-joe-rogan-podcast-removed/\">The Washington Post</a>\n &#8226;  <a href=\"https://www.nytimes.com/2022/02/05/arts/music/joe-rogan-spotify-apology-slur.html\">The New York Times</a>\n &#8226;  <a href=\"https://www.rollingstone.com/culture/culture-news/spotify-removes-joe-rogan-experience-podcast-episodes-1295727/\">Rolling Stone</a>\n &#8226;  <a href=\"https://www.theverge.com/22918697/joe-rogan-experience-podcast-episodes-disappear-controversy\">The Verge</a>\n &#8226;  <a href=\"https://www.bloomberg.com/news/articles/2022-02-05/joe-rogan-apologizes-for-using-racial-slur-on-his-podcast\">Bloomberg</a>\n &#8226;  <a href=\"https://variety.com/2022/digital/news/spotify-removes-joe-rogan-episodes-n-word-1235172972/\">Variety</a>\n &#8226;  <a href=\"https://edition.cnn.com/2022/02/05/media/joe-rogan-racial-slur-apology-india-arie/index.html\">CNN</a>\n &#8226;  <a href=\"https://www.latimes.com/entertainment-arts/story/2022-02-05/joe-rogan-apologizes-for-using-n-word?utm_source=pocket_mylist\">LA Times</a>\n &#8226;  <a href=\"https://www.businessinsider.com/spotify-deletes-70-joe-rogan-podcast-episodes-including-alex-jones-2022-2?r=US&IR=T\">Business Insider</a>\n &#8226;  <a href=\"https://nypost.com/2022/02/05/spotify-has-removed-over-100-episodes-of-joe-rogans-podcast/\">New York Post</a>\n &#8226;  <a href=\"https://www.dailymail.co.uk/news/article-10479127/Spotify-purges-70-Joe-Rogan-episodes-defiant-host-returns-airwaves-Says-lockdowns-dont-work.html\">Daily Mail</a>\n &#8226;  <a href=\"https://www.wsj.com/articles/joe-rogan-racial-slur-spotify-11644275660\">The Wall Street Journal</a>\n &#8226;  <a href=\"https://www.forbes.com/sites/lisakim/2022/02/05/spotify-pulls-more-than-110-episodes-of-joe-rogans-podcast/?sh=79b42a5d1539\">Forbes</a>\n &#8226;  <a href=\"https://www.newsweek.com/spotify-draws-line-between-slurs-covid-removing-some-joe-rogan-episodes-1676887\">Newsweek</a>\n &#8226;  <a href=\"https://www.huffpost.com/entry/joe-rogan-experience-episodes-removed-spotify_n_61fef043e4b0f8a1b8453a83\">HuffPost</a>\n &#8226;  <a href=\"https://www.independent.co.uk/arts-entertainment/music/news/joe-rogan-podcast-episodes-removed-b2009035.html\">The Independent</a>\n &#8226;  <a href=\"https://www.aftenposten.no/kultur/i/RrnjjW/joe-rogan-ber-om-unnskyldning-for-aa-ha-brukt-n-ordet-i-sin-podkast\">Aftenposten</a>\n\n## Sponsors 💜\n\n<a href=\"https://github.com/cotterjd\">Jordan Cotter</a> &#8226;\n\n</div>\n"
  },
  {
    "path": "client/.eslintrc.json",
    "content": "{\n  \"env\": {\n    \"browser\": true,\n    \"es2021\": true,\n    \"jest\": true\n  },\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:react/recommended\",\n    \"plugin:react-hooks/recommended\",\n    \"prettier\"\n  ],\n  \"parserOptions\": {\n    \"ecmaFeatures\": {\n      \"jsx\": true\n    },\n    \"ecmaVersion\": 12,\n    \"sourceType\": \"module\"\n  },\n  \"plugins\": [\"react\", \"react-hooks\"],\n  \"rules\": {\n    \"react/prop-types\": 0,\n    \"react/jsx-uses-react\": \"off\",\n    \"react/react-in-jsx-scope\": \"off\",\n    \"react-hooks/exhaustive-deps\": \"warn\",\n    \"prefer-const\": [\n      \"warn\",\n      {\n        \"destructuring\": \"all\",\n        \"ignoreReadBeforeAssign\": false\n      }\n    ],\n    \"prefer-template\": \"warn\"\n  },\n  \"ignorePatterns\": [\"node_modules/\", \"coverage/\", \"build/\"]\n}\n"
  },
  {
    "path": "client/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"favicon.png\" />\n    <script\n      async\n      src=\"https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2393608933506016\"\n      crossorigin=\"anonymous\"></script>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta\n      name=\"description\"\n      content=\"Detects missing Spotify episodes of The Joe Rogan Experience podcast\" />\n    <link rel=\"apple-touch-icon\" href=\"logo192.png\" />\n    <title>JRE Missing</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n\n    <script type=\"module\" src=\"/src/index.jsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"name\": \"client\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@types/node\": \"^20.8.9\",\n    \"@types/react\": \"^18.2.33\",\n    \"@vitejs/plugin-react-swc\": \"^3.4.0\",\n    \"classnames\": \"^2.3.2\",\n    \"date-fns\": \"^2.30.0\",\n    \"date-fns-tz\": \"^2.0.0\",\n    \"lodash\": \"^4.17.21\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-text-transition\": \"^3.1.0\",\n    \"react-tooltip\": \"^5.21.6\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^4.5.0\",\n    \"vite-plugin-svgr\": \"^4.1.0\",\n    \"vite-tsconfig-paths\": \"^4.2.1\",\n    \"web-vitals\": \"^3.5.0\"\n  },\n  \"scripts\": {\n    \"start\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"serve\": \"vite preview\",\n    \"test\": \"vitest\",\n    \"test:coverage\": \"vitest run --coverage --watch=false\"\n  },\n  \"devDependencies\": {\n    \"@vitest/coverage-v8\": \"^0.34.6\",\n    \"eslint\": \"^8.52.0\",\n    \"eslint-config-prettier\": \"^9.0.0\",\n    \"eslint-plugin-react\": \"^7.33.2\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"jsdom\": \"^22.1.0\",\n    \"vitest\": \"^0.34.6\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\"\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "client/public/ads.txt",
    "content": "google.com, pub-2393608933506016, DIRECT, f08c47fec0942fa0"
  },
  {
    "path": "client/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "client/src/components/AmountInfo.jsx",
    "content": "import classnames from \"classnames\";\nimport styles from \"./AmountInfo.module.css\";\nimport { getClientLocalTime, formatMinutesToTimeAmountString } from \"../utils\";\nimport Checkmark from \"../icons/AmountInfoIcon.svg\";\nimport SkeletonText from \"../skeletons/SkeletonText.jsx\";\n\nconst AmountInfo = ({ data, showSkeleton, setListShown }) => {\n  if (showSkeleton) return <SkeletonText />;\n  if (!data || !data.missingEpisodes || !data.shortenedEpisodes) return null;\n\n  const { missingEpisodes, shortenedEpisodes } = data;\n\n  const lastChecked = data.lastCheckedInMs;\n  const lastCheckedMinutes = lastChecked\n    ? Math.floor((new Date() - new Date(lastChecked)) / 60000)\n    : 0;\n\n  const lastCheckedString = formatMinutesToTimeAmountString(lastCheckedMinutes);\n  const lastCheckedDate = getClientLocalTime(lastChecked, \"PP HH:mm\");\n\n  const dateTimeHTMLAttribute = getClientLocalTime(lastChecked, \"yyyy-MM-dd HH:mm:ss.sss\");\n\n  return (\n    <div className={styles.AmountInfo}>\n      <button onClick={() => setListShown(\"removed\")} className={styles.AmountInfoItem}>\n        <span\n          className={classnames(styles.count, {\n            [styles.NoAmount]: missingEpisodes.length === 0,\n          })}>\n          {missingEpisodes.length}\n        </span>{\" \"}\n        episodes are missing from Spotify.\n      </button>\n      <button onClick={() => setListShown(\"shortened\")} className={styles.AmountInfoItem}>\n        <span\n          className={classnames(styles.count, {\n            [styles.NoAmount]: shortenedEpisodes.length === 0,\n          })}>\n          {shortenedEpisodes.length}\n        </span>{\" \"}\n        episode\n        {shortenedEpisodes.length === 1 ? \"\" : \"s\"}{\" \"}\n        {shortenedEpisodes.length == 1 ? \"has\" : \"have\"} been shortened.\n      </button>\n      <div className={styles.LastChecked}>\n        <p>\n          Last checked: {lastCheckedString} ago\n          <time dateTime={dateTimeHTMLAttribute}> ({lastCheckedDate})</time>\n        </p>\n        <Checkmark className={styles.Checkmark} />\n      </div>\n    </div>\n  );\n};\n\nexport default AmountInfo;\n"
  },
  {
    "path": "client/src/components/AmountInfo.module.css",
    "content": ".AmountInfo {\n  font-size: 2rem;\n}\n\n.AmountInfoItem {\n  display: block;\n  white-space: nowrap;\n}\n\n.AmountInfoItem .count {\n  color: var(--color-red);\n  font-weight: 700;\n  position: relative;\n}\n\n.AmountInfoItem .count:after {\n  content: \"\";\n  position: absolute;\n  width: 100%;\n  height: 0;\n  right: 0;\n  bottom: 1px;\n  border-bottom: 2px solid var(--color-red);\n}\n.AmountInfoItem .count.NoAmount:after {\n  border-color: var(--color-green);\n}\n\n.LastChecked {\n  font-size: 1.4rem;\n  display: flex;\n  text-align: center;\n  margin-top: 1px;\n  color: var(--color-grey);\n  margin-bottom: 3rem;\n}\n\n.count.NoAmount {\n  color: var(--color-green);\n}\n\n.Checkmark {\n  margin: -1px 0 0 6px;\n  color: var(--color-green);\n}\n\n@media (max-width: 1350px) {\n  .AmountInfo {\n    font-size: 1.8rem;\n    margin: 4.5rem 0 1.75rem;\n    text-align: center;\n  }\n\n  .AmountInfoItem {\n    margin: 0 auto;\n  }\n\n  .AmountInfo .count:after {\n    bottom: 0;\n    width: 95%;\n  }\n\n  .LastChecked {\n    justify-content: center;\n    margin-left: 1.25rem;\n    margin-top: 0.7rem;\n  }\n\n  .LastChecked time {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "client/src/components/App.css",
    "content": ".App {\n  display: flex;\n  position: relative;\n}\n\n.left {\n  position: fixed;\n  top: 26rem;\n  bottom: 19rem;\n  left: 35rem;\n}\n\n.right {\n  display: flex;\n  align-items: flex-start;\n  margin: 9rem 0rem 9rem 49vw;\n}\n\n@media (max-width: 1700px) {\n  .left {\n    left: 20rem;\n    top: 17rem;\n  }\n}\n\n@media (max-width: 1450px) {\n  .left {\n    left: 14rem;\n  }\n}\n\n@media (max-width: 1350px) {\n  .App {\n    flex-direction: column;\n    align-items: center;\n    margin-bottom: 6rem;\n  }\n\n  .left,\n  .right {\n    position: unset;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    margin: unset;\n  }\n\n  .right {\n    margin-top: 2rem;\n  }\n}\n\n@media (max-width: 750px) {\n  .App {\n    padding: 0 5rem;\n  }\n}\n\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n"
  },
  {
    "path": "client/src/components/App.jsx",
    "content": "import \"./App.css\";\nimport { useState, useEffect } from \"react\";\nimport useFetch from \"../hooks/useFetch\";\nimport useMinLoadingTime from \"../hooks/useMinLoadingTime\";\nimport Error from \"./Error\";\nimport Github from \"./Github\";\nimport Header from \"./Header\";\nimport AmountInfo from \"./AmountInfo\";\nimport EpisodeList from \"./EpisodeList\";\nimport Sort from \"./Sort\";\nimport Searchbox from \"./Searchbox\";\nimport ScrollButton from \"./ScrollButton\";\nimport Contact from \"./Contact\";\nimport Sponsor from \"./Sponsor\";\nimport Coffee from \"./Coffee\";\nimport useScroll from \"../hooks/useScroll\";\n\nfunction App() {\n  const { data, error, isPending } = useFetch(\n    `${import.meta.env.VITE_API_BASE_URL}/api/episodes`\n  );\n  const minLoadingTimeElapsed = useMinLoadingTime(400);\n  const [shouldShakeEpisodes, setShouldShakeEpisodes] = useState(false);\n  const [missingEpisodesShown, setMissingEpisodesShown] = useState([]);\n  const [shortenedEpisodesShown, setShortenedEpisodesShown] = useState([]);\n  const [listShown, setListShown] = useState(\"removed\");\n  const [searchText, setSearchText] = useState(\"\");\n\n  const listMap = {\n    removed: {\n      episodes: missingEpisodesShown,\n      allEpisodes: data?.missingEpisodes || [],\n      setEpisodes: setMissingEpisodesShown,\n    },\n    shortened: {\n      episodes: shortenedEpisodesShown,\n      allEpisodes: data?.shortenedEpisodes || [],\n      setEpisodes: setShortenedEpisodesShown,\n    },\n  };\n\n  const currentList = listMap[listShown];\n\n  useEffect(() => {\n    setMissingEpisodesShown(data?.missingEpisodes || []);\n    setShortenedEpisodesShown(data?.shortenedEpisodes || []);\n  }, [data]);\n\n  const shakeEpisodes = () => {\n    setShouldShakeEpisodes(true);\n    setTimeout(() => {\n      setShouldShakeEpisodes(false);\n    }, 1000);\n  };\n\n  const { scrollTarget, scrollable } = useScroll({\n    refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],\n  });\n\n  const showSkeleton = isPending || !minLoadingTimeElapsed;\n\n  const resetCurrentEpisodes = () => {\n    currentList.setEpisodes(currentList.allEpisodes);\n    setSearchText(\"\");\n  };\n\n  return (\n    <div className=\"App\">\n      <section className=\"left\">\n        <Github />\n        <Sponsor />\n        <Coffee />\n        <Header />\n        <Contact />\n        {error ? (\n          <Error error={error} />\n        ) : (\n          <>\n            <AmountInfo data={data} showSkeleton={showSkeleton} setListShown={setListShown} />\n            <Searchbox\n              {...currentList}\n              shakeEpisodes={shakeEpisodes}\n              searchText={searchText}\n              setSearchText={setSearchText}\n            />\n          </>\n        )}\n      </section>\n\n      {!error && (\n        <section className=\"right\">\n          <Sort\n            listShown={listShown}\n            setEpisodes={currentList.setEpisodes}\n            episodes={currentList.episodes}\n          />\n          <EpisodeList\n            missingEpisodesShown={missingEpisodesShown}\n            shortenedEpisodesShown={shortenedEpisodesShown}\n            shouldShake={shouldShakeEpisodes}\n            showSkeleton={showSkeleton}\n            searchText={searchText}\n            listShown={listShown}\n            setListShown={setListShown}\n            resetCurrentEpisodes={resetCurrentEpisodes}\n          />\n          <ScrollButton\n            dataPending={isPending}\n            minLoadingTimeElapsed={minLoadingTimeElapsed}\n            scrollTarget={scrollTarget}\n            scrollable={scrollable}\n          />\n        </section>\n      )}\n    </div>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "client/src/components/ChangeDetails.jsx",
    "content": "import { useState } from \"react\";\nimport classnames from \"classnames\";\nimport Disclosure from \"./Disclosure\";\nimport styles from \"./ChangeDetails.module.css\";\nimport Chavron from \"../icons/chavron.svg\";\n\nconst ChangeDetails = ({ episode }) => {\n  const [open, setOpen] = useState(false);\n  const disclosureId = `${episode.full_name}-toggle`;\n\n  const [latestChange, ...restOfChanges] = episode.changes;\n\n  const disclosureProps = {\n    isOpen: open,\n    onClick: () => {\n      setOpen((open) => !open);\n    },\n    ariaControls: `${episode.full_name}-change-history-wrapper`,\n    id: disclosureId,\n  };\n\n  return (\n    <div className={styles.ChangeDetails}>\n      <div className={styles.ChangeDisplay}>\n        <p>\n          <span className={styles.displayTime}>{latestChange.old_duration_string}</span>\n        </p>\n        <span>&gt;</span>\n        <p>\n          <span className={styles.displayTime}>{latestChange.new_duration_string}</span>\n        </p>\n      </div>\n      {restOfChanges.length > 0 && (\n        <div className={styles.restOfChanges}>\n          <div className={styles.headingWrapper}>\n            <p className={styles.heading}>\n              Has been changed {restOfChanges.length} {!episode.isOriginalLength && \"more\"}{\" \"}\n              time\n              {restOfChanges.length === 1 ? \"\" : \"s\"} previously.\n            </p>\n            <Disclosure className={styles.historyToggle} {...disclosureProps}>\n              <span>View change history</span>\n              <Chavron\n                className={classnames(styles.Chavron, {\n                  [styles.open]: open,\n                })}\n              />\n            </Disclosure>\n          </div>\n          <div\n            aria-expanded={open}\n            aria-labelledby={disclosureId}\n            id={`${episode.full_name}-change-history-wrapper`}>\n            {open && (\n              <div className={styles.restOfChangesItems}>\n                {[latestChange, ...restOfChanges].map((change) => {\n                  return (\n                    <ChangeDisplay\n                      change={change}\n                      key={`${episode.id}-${change.date.ms}-${change.new_duration}`}\n                    />\n                  );\n                })}\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\nconst ChangeDisplay = ({ change }) => {\n  const {\n    date: { formatted, htmlAttribute },\n    old_duration_string,\n    new_duration_string,\n  } = change;\n  return (\n    <div className={styles.ChangeDisplayWrapper}>\n      <time dateTime={htmlAttribute} className={styles.ChangeDisplayDate}>\n        {formatted}\n      </time>\n      <p className={styles.ChangeDisplay}>\n        <span className={styles.displayTime}>{old_duration_string}</span>\n        <span>&gt;</span>\n        <span className={styles.displayTime}>{new_duration_string}</span>\n      </p>\n    </div>\n  );\n};\n\nexport default ChangeDetails;\n"
  },
  {
    "path": "client/src/components/ChangeDetails.module.css",
    "content": ".ChangeDetails {\n  display: flex;\n  flex-direction: column;\n  font-size: var(--font-size-small);\n  color: var(--color-grey-secondary);\n  margin-top: 0.75rem;\n}\n\n.headingWrapper {\n  display: flex;\n  align-items: center;\n  flex-wrap: wrap;\n  gap: 0.5rem;\n  background: var(--color-black-secondary);\n  padding: 2px 8px 4px;\n  border-radius: 5px;\n}\n\n.heading {\n  color: var(--color-white);\n  font-size: var(--font-size-small);\n  font-weight: 400;\n  white-space: nowrap;\n}\n\n.ChangeDisplay {\n  display: flex;\n}\n\n.ChangeDisplayWrapper {\n  display: flex;\n  flex-direction: column;\n}\n\n.ChangeDisplay {\n  display: flex;\n  flex-direction: row;\n  gap: 1rem;\n}\n\n.historyToggle {\n  color: var(--color-grey);\n  white-space: nowrap;\n}\n\n.historyToggle svg {\n  width: 20px;\n  height: 20px;\n}\n\n.ChangeDisplayDate {\n  color: var(--color-grey);\n  margin-bottom: 2px;\n}\n\n.restOfChanges {\n  width: fit-content;\n  margin-top: 1rem;\n}\n\n.restOfChangesItems {\n  display: flex;\n  flex-direction: column;\n  background-color: var(--color-black-secondary);\n  gap: 1.75rem;\n  padding: 1.5rem;\n  margin-top: -4px;\n}\n\n.Chavron {\n  transform: rotate(180deg) scale(0.65);\n  margin-top: 3px;\n  transition: transform 0.25s ease-out;\n}\n\n.Chavron.open {\n  transform: rotate(0deg) scale(0.65);\n  margin-top: 3px;\n}\n\n@media (max-width: 1350px) {\n  .ChangeDetails {\n    align-items: center;\n  }\n}\n\n@media (max-width: 400px) {\n  .restOfChanges {\n    margin-top: 1.75rem;\n  }\n\n  .headingWrapper {\n    flex-direction: column;\n    gap: 0;\n    padding: 6px 32px;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Coffee.jsx",
    "content": "import styles from \"./Coffee.module.css\";\n\nconst Coffee = () => {\n  return (\n    <>\n      <a\n        className={styles.Coffee}\n        href=\"https://www.buymeacoffee.com/henbc13\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        aria-label=\"Buy Me a Coffee\">\n        <img\n          src=\"https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png\"\n          alt=\"Buy Me a Coffee logo\"\n          className={styles.CoffeeLogo}\n        />\n      </a>\n    </>\n  );\n};\n\nexport default Coffee;\n"
  },
  {
    "path": "client/src/components/Coffee.module.css",
    "content": ".Coffee {\n  position: fixed;\n  top: 3.65rem;\n  left: 36rem;\n  font-weight: 500;\n  font-size: 16px;\n  cursor: pointer;\n}\n\n.CoffeeLogo {\n  max-height: 30px;\n}\n\n@media (max-width: 1350px) {\n  .Coffee {\n    left: 24rem;\n    position: absolute;\n  }\n}\n\n@media (max-width: 750px) {\n  .Coffee {\n    top: 1.65rem;\n    left: 21rem;\n  }\n}\n\n@media (max-width: 380px) {\n  .Coffee {\n    top: 1.5rem;\n    left: 15rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Contact.jsx",
    "content": "import styles from \"./Contact.module.css\";\n\nimport EmailIcon from \"../icons/email.svg\";\n\nconst Contact = () => {\n  return (\n    <a\n      href=\"mailto:henbc13@gmail.com\"\n      className={styles.contact}\n      aria-label=\"send me an email\">\n      <span>contact me</span>\n      <EmailIcon className={styles.contactIcon} />\n    </a>\n  );\n};\n\nexport default Contact;\n"
  },
  {
    "path": "client/src/components/Contact.module.css",
    "content": ".contact {\n  position: fixed;\n  display: flex;\n  align-items: center;\n  font-size: var(--font-size-medium);\n  top: 4rem;\n  right: 5rem;\n}\n\n.contactIcon {\n  height: 2rem;\n  width: 2rem;\n  margin-left: 1rem;\n  transition: transform 0.2s;\n}\n\n.contact:hover .contactIcon {\n  transform: scale(1.2);\n  cursor: pointer;\n}\n\n.contact:active .contactIcon {\n  transform: scale(1);\n}\n\n@media (max-width: 1350px) {\n  .contact {\n    position: absolute;\n  }\n}\n\n@media (max-width: 750px) {\n  .contact {\n    top: 2rem;\n    right: 2rem;\n  }\n}\n@media (max-width: 450px) {\n  .contact span {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Disclosure.jsx",
    "content": "import styles from \"./Disclosure.module.css\";\nimport classnames from \"classnames\";\n\nconst Disclosure = ({ isOpen, onClick, className, ariaControls, id, children }) => {\n  return (\n    <button\n      id={id}\n      aria-controls={ariaControls}\n      className={classnames(className, styles.Disclosure, {\n        [styles.open]: isOpen,\n      })}\n      onClick={onClick}>\n      {children}\n    </button>\n  );\n};\n\nexport default Disclosure;\n"
  },
  {
    "path": "client/src/components/Disclosure.module.css",
    "content": ".Disclosure {\n  display: flex;\n  align-items: center;\n  gap: 1px;\n  transition: all 0.25s;\n  cursor: pointer;\n  color: var(--color-grey-secondary);\n  font-size: var(--font-size-small);\n}\n\n.Disclosure:hover {\n  color: var(--color-white-secondary);\n}\n"
  },
  {
    "path": "client/src/components/Episode.jsx",
    "content": "import styles from \"./Episode.module.css\";\nimport Tag from \"./Tag\";\n\nconst Episode = ({ variant, name, number, date, isNew, isOriginalLength }) => {\n  // eslint-disable-next-line no-unused-vars\n  let [_, ...guest] = name.split(\"-\");\n  guest = guest.join(\"-\");\n\n  return (\n    <div className={styles.epContent}>\n      <div className={styles.tagsWrapper}>\n        {isNew && <Tag variant=\"new\">new</Tag>}\n        {variant === \"shortened\" && isOriginalLength && (\n          <Tag\n            variant=\"originalLength\"\n            toolTip=\"This episode is now as long as it originally was before it was shortened the first time. This does not mean nothing has been edited out since its release. It simply means that the current duration matches its original duration. The editing history is documented here.\">\n            original length\n          </Tag>\n        )}\n      </div>\n      <div className={styles.epName}>\n        {number ? (\n          <>\n            <span className={styles.epNumber}>#{number}</span>\n            <span className={styles.epGuest}>{guest}</span>\n          </>\n        ) : (\n          name\n        )}\n      </div>\n      {date && (\n        <span className={styles.timeDetail}>\n          {variant === \"removed\" ? \"Removed\" : \"Shortened\"} on{\" \"}\n          <time dateTime={date.htmlAttribute}>{date.formatted}</time>\n        </span>\n      )}\n    </div>\n  );\n};\n\nexport default Episode;\n"
  },
  {
    "path": "client/src/components/Episode.module.css",
    "content": ".epContent {\n  display: flex;\n  flex-direction: column;\n}\n\n.tagsWrapper {\n  display: flex;\n  gap: 9px;\n  margin-bottom: 8px;\n}\n\n.epName {\n  display: flex;\n  align-items: center;\n  flex-wrap: wrap;\n  gap: 0.75rem;\n}\n\n.epNumber {\n  color: var(--color-orange);\n}\n\n.epGuest {\n  text-align: center;\n}\n\n.timeDetail {\n  font-size: var(--font-size-small);\n  letter-spacing: 0.1px;\n  color: var(--color-grey);\n}\n\n@media (max-width: 1350px) {\n  .EpisodeItem {\n    display: unset;\n    text-align-last: center;\n    align-items: unset;\n    width: 100%;\n  }\n\n  .epContent {\n    align-items: center;\n  }\n\n  .epName {\n    justify-content: center;\n    gap: 0.5rem;\n  }\n\n  .timeDetail {\n    display: block;\n    margin: 4px 0 0 0;\n    font-size: var(--font-size-small);\n  }\n}\n"
  },
  {
    "path": "client/src/components/EpisodeList.jsx",
    "content": "import classnames from \"classnames\";\nimport styles from \"./EpisodeList.module.css\";\nimport Episode from \"./Episode\";\nimport SkeletonList from \"../skeletons/SkeletonList.jsx\";\nimport ListTabs from \"./ListTabs\";\nimport ChangeDetails from \"./ChangeDetails\";\n\nconst EpisodeList = ({\n  missingEpisodesShown,\n  shortenedEpisodesShown,\n  shouldShake,\n  showSkeleton,\n  searchText,\n  listShown,\n  setListShown,\n  resetCurrentEpisodes,\n}) => {\n  if (showSkeleton) return <SkeletonList />;\n  if (!missingEpisodesShown && !shortenedEpisodesShown) return null;\n\n  const classesEpList = classnames(styles.EpisodeList, {\n    shake: shouldShake,\n  });\n\n  const listIdRemoved = \"episode-list-removed\";\n  const listIdShortened = \"episode-list-shortened\";\n  const tabIdRemoved = \"tab-removed\";\n  const tabIdShortened = \"tab-shortened\";\n  return (\n    <div className={styles.wrapper}>\n      <ListTabs\n        listShown={listShown}\n        setListShown={setListShown}\n        resetCurrentEpisodes={resetCurrentEpisodes}\n        listIdRemoved={listIdRemoved}\n        listIdShortened={listIdShortened}\n        tabIdRemoved={tabIdRemoved}\n        tabIdShortened={tabIdShortened}\n      />\n      <>\n        {listShown === \"removed\" ? (\n          <RemovedList\n            searchText={searchText}\n            episodes={missingEpisodesShown}\n            className={classesEpList}\n            id={listIdRemoved}\n            ariaLabelledBy={tabIdRemoved}\n          />\n        ) : (\n          <ShortenedList\n            searchText={searchText}\n            episodes={shortenedEpisodesShown}\n            className={classesEpList}\n            id={listIdShortened}\n            ariaLabelledBy={tabIdShortened}\n          />\n        )}\n      </>\n    </div>\n  );\n};\n\nconst RemovedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {\n  return (\n    <ul className={className} role=\"tabpanel\" id={id} aria-labelledby={ariaLabelledBy}>\n      {episodes.length > 0\n        ? episodes.map((ep) => (\n            <li\n              className={styles.EpisodeItem}\n              key={ep.full_name + ep.episode_number}\n              lang=\"en\">\n              <Border />\n              <Episode\n                variant=\"removed\"\n                name={ep.full_name}\n                number={ep.episode_number}\n                date={ep.date}\n                isNew={ep.isNew}\n              />\n            </li>\n          ))\n        : !searchText && (\n            <div className={styles.NoEpisodesMessage}>\n              No episodes have been removed yet. Check back later!\n            </div>\n          )}\n    </ul>\n  );\n};\n\nconst ShortenedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {\n  return (\n    <ul className={className} role=\"tabpanel\" id={id} aria-labelledby={ariaLabelledBy}>\n      {episodes.length > 0\n        ? episodes.map((ep, i) => {\n            return (\n              <li\n                className={classnames(styles.EpisodeItem, styles.shortenedEpisode)}\n                key={ep.full_name + ep.episode_number}\n                lang=\"en\">\n                {i !== 0 && <Border />}\n                <Episode\n                  variant=\"shortened\"\n                  name={ep.full_name}\n                  number={ep.episode_number}\n                  date={ep.changes[0].date}\n                  isNew={ep.isNew}\n                  isOriginalLength={ep.isOriginalLength}\n                />\n                <ChangeDetails episode={ep} />\n              </li>\n            );\n          })\n        : !searchText && (\n            <div className={styles.NoEpisodesMessage}>\n              No episodes have been shortened yet. Check back later!\n            </div>\n          )}\n    </ul>\n  );\n};\n\nconst Border = ({ visible }) => {\n  return (\n    <span\n      className={classnames(styles.Border, {\n        [styles.visible]: visible,\n      })}></span>\n  );\n};\n\nexport default EpisodeList;\n"
  },
  {
    "path": "client/src/components/EpisodeList.module.css",
    "content": ".wrapper .EpisodeList {\n  position: unset;\n  font-size: 1.4rem;\n  list-style-type: none;\n  display: flex;\n  flex-direction: column;\n  gap: 4.5rem;\n}\n\n.EpisodeItem {\n  font-size: var(--font-size-medium);\n  hyphens: auto;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n}\n\n.NoEpisodesMessage {\n  color: var(--color-grey);\n}\n\n.Border {\n  display: initial;\n  background-color: var(--color-grey);\n  height: 1px;\n  width: 75%;\n  opacity: 0.25;\n  margin-bottom: 4rem;\n}\n\n.Border:not(.visible) {\n  display: none;\n}\n\n@media (max-width: 1350px) {\n  .EpisodeList {\n    gap: 3.5rem;\n    justify-items: center;\n  }\n  .EpisodeItem {\n    flex-direction: column;\n    align-items: center;\n  }\n\n  .Border:not(.visible) {\n    display: initial;\n  }\n}\n\n@media (max-width: 600px) {\n  .EpisodeList {\n    font-size: 1.2rem;\n    margin-right: 0.6rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Error.jsx",
    "content": "import AlertIcon from \"../icons/alertIcon.svg\";\nimport styles from \"./Error.module.css\";\n\nconst Error = ({ error }) => {\n  return (\n    <div className={styles.error}>\n      <AlertIcon className={styles.icon} />\n      {error}\n    </div>\n  );\n};\n\nexport default Error;\n"
  },
  {
    "path": "client/src/components/Error.module.css",
    "content": ".error {\n  display: flex;\n  align-items: center;\n  font-size: var(--font-size-medium);\n}\n\n.icon {\n  margin-right: 1rem;\n  width: 2.4rem;\n  height: 2.4rem;\n}\n"
  },
  {
    "path": "client/src/components/Github.jsx",
    "content": "import GitHubLogo from \"../icons/github-1.png\";\nimport styles from \"./Github.module.css\";\n\nconst Github = () => {\n  return (\n    <>\n      <a\n        className={styles.Github}\n        href=\"https://github.com/HenB13/jre-missing\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        aria-label=\"view code on GitHub\">\n        <img src={GitHubLogo} alt=\"github logo\" className={styles.GithubLogo} />{\" \"}\n        <span className={styles.GithubText}> {\"<--\"} View the code!</span>\n      </a>\n    </>\n  );\n};\n\nexport default Github;\n"
  },
  {
    "path": "client/src/components/Github.module.css",
    "content": ".Github {\n  position: fixed;\n  top: 4rem;\n  left: 5rem;\n  display: flex;\n  align-items: center;\n}\n\n.GithubLogo {\n  transition: transform 0.2s;\n  height: 2.4rem;\n  width: 2.4rem;\n  margin-right: 1rem;\n}\n\n.Github:hover .GithubLogo {\n  transform: scale(1.2);\n  cursor: pointer;\n}\n\n.Github:active .GithubLogo {\n  transform: scale(1);\n}\n\n.GithubText {\n  font-size: var(--font-size-medium);\n  font-style: italic;\n  color: var(--color-white);\n}\n\n@media (max-width: 1350px) {\n  .Github {\n    position: absolute;\n  }\n\n  .GithubText {\n    display: none;\n  }\n}\n\n@media (max-width: 750px) {\n  .Github {\n    top: 2rem;\n    left: 2rem;\n  }\n}\n\n@media (max-width: 380px) {\n  .Github {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Header.jsx",
    "content": "import styles from \"./Header.module.css\";\n\nconst Header = () => {\n  return (\n    <header className={styles.Header}>\n      <h1>\n        <span>JRE</span> MISSING\n      </h1>\n      <p className={styles.intro}>\n        This website automatically detects episodes of{\" \"}\n        <a\n          href=\"https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\">\n          <span>The Joe Rogan Experience</span>\n          <br /> podcast{\" \"}\n        </a>{\" \"}\n        that are currently not available on the Spotify platform by comparing the official\n        Spotify API with a database of all episodes ever released. It also detects if episodes\n        have been shortened in duration.\n      </p>\n    </header>\n  );\n};\n\nexport default Header;\n"
  },
  {
    "path": "client/src/components/Header.module.css",
    "content": ".Header {\n  margin-bottom: 10rem;\n}\n\nh1 {\n  font-size: 8rem;\n\n  font-family: \"Teko\", sans-serif;\n  font-style: italic;\n  letter-spacing: -3px;\n  font-weight: 900;\n  margin-left: -2px;\n  color: var(--color-red);\n}\n\nh1 span {\n  color: var(--color-white);\n}\n\n.intro {\n  font-size: var(--font-size-medium);\n  max-width: 47rem;\n}\n\n.intro span {\n  color: var(--color-orange);\n  font-weight: 600;\n  transition: color 0.75s;\n}\n\n.intro span {\n  position: relative;\n}\n\n.intro span:after {\n  content: \"\";\n  position: absolute;\n  bottom: 0.05em;\n  left: 0;\n  width: 100%;\n  height: 1px;\n  min-height: 1px;\n  z-index: -1;\n  background-color: var(--color-orange);\n  transform: scale(0, 1);\n  transition: transform 0.25s ease;\n}\n\n.intro span:hover:after {\n  transform: scale(1, 1);\n}\n\n.brMobile {\n  display: none;\n}\n\n@media (max-width: 1350px) {\n  .Header {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    margin-top: 15rem;\n    margin-bottom: 0;\n  }\n  h1 {\n    font-size: clamp(3rem, 5rem, 5vw);\n    letter-spacing: unset;\n    margin-bottom: 2rem;\n  }\n  .intro {\n    text-align: center;\n    font-size: clamp(1.4rem, 1.6rem, 2.5vw);\n    max-width: 40rem;\n  }\n\n  .intro a {\n    display: block;\n    width: max-content;\n    margin: 0 auto;\n  }\n  br {\n    display: none;\n  }\n}\n\n@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {\n  .Header {\n    margin-top: 10rem;\n  }\n}\n\n@media (orientation: landscape) AND (max-height: 650px) {\n  .Header {\n    margin-top: 11rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/ListTabs.jsx",
    "content": "import styles from \"./ListTabs.module.css\";\nimport classnames from \"classnames\";\n\nconst ListTabs = ({\n  listShown,\n  setListShown,\n  resetCurrentEpisodes,\n  listIdRemoved,\n  listIdShortened,\n  tabIdRemoved,\n  tabIdShortened,\n}) => {\n  return (\n    <div className={styles.ListTab} role=\"tablist\" aria-orientation=\"horizontal\">\n      <Option\n        title=\"Removed\"\n        isSelected={listShown === \"removed\"}\n        onClick={() => {\n          setListShown(\"removed\");\n          resetCurrentEpisodes();\n        }}\n        id={tabIdRemoved}\n        ariaControls={listIdRemoved}\n      />\n      <div className={styles.divider}></div>\n      <Option\n        title=\"Shortened\"\n        isSelected={listShown === \"shortened\"}\n        onClick={() => {\n          setListShown(\"shortened\");\n          resetCurrentEpisodes();\n        }}\n        id={tabIdShortened}\n        ariaControls={listIdShortened}\n      />\n    </div>\n  );\n};\n\nconst Option = ({ title, onClick, isSelected, ariaControls, id }) => {\n  return (\n    <button\n      id={id}\n      className={classnames(styles.option, {\n        [styles.selected]: isSelected,\n      })}\n      onClick={onClick}\n      role=\"tab\"\n      aria-selected={isSelected}\n      aria-controls={ariaControls}\n      type=\"button\">\n      {title}\n    </button>\n  );\n};\n\nexport default ListTabs;\n"
  },
  {
    "path": "client/src/components/ListTabs.module.css",
    "content": ".ListTab {\n  font-size: var(--font-size-small);\n  display: flex;\n  gap: 2rem;\n  margin-bottom: 4rem;\n}\n\n.option {\n  transition: color, border-color 0.25s;\n  padding-bottom: 0.5rem;\n  color: var(--color-grey);\n  transition: all 0.25s ease-out;\n  border-bottom: 1px solid;\n  border-color: var(--color-black);\n}\n\n.option.selected {\n  border-color: var(--color-white);\n}\n\n.option.selected,\n.option:hover {\n  color: var(--color-white);\n}\n\n.divider {\n  max-height: 50%;\n  width: 1px;\n  background-color: var(--color-grey);\n  opacity: 0.3;\n}\n\n@media (max-width: 1350px) {\n  .ListTab {\n    justify-content: center;\n    margin-bottom: 5rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/ScrollButton.jsx",
    "content": "import TextTransition, { presets } from \"react-text-transition\";\nimport classnames from \"classnames\";\nimport ArrowDown from \"../icons/ScrollButtonIcon.svg\";\nimport styles from \"./ScrollButton.module.css\";\n\nconst ScrollButton = ({ dataPending, minLoadingTimeElapsed, scrollTarget, scrollable }) => {\n  const shouldHide = !scrollable || dataPending || !minLoadingTimeElapsed;\n\n  function handleClick() {\n    window.scroll({\n      top: scrollTarget === \"top\" ? 0 : document.body.clientHeight,\n      left: 0,\n      behavior: \"smooth\",\n    });\n  }\n\n  return (\n    <button\n      className={classnames(styles.ScrollButton, {\n        [styles.up]: scrollTarget === \"top\",\n        [styles.hidden]: shouldHide,\n      })}\n      disabled={shouldHide}\n      aria-label={`scroll to ${scrollTarget}`}\n      onClick={handleClick}>\n      <div className={styles.ScrollText}>\n        To{\" \"}\n        <TextTransition\n          springConfig={presets.gentle}\n          inline={true}\n          direction={scrollTarget === \"top\" ? \"up\" : \"down\"}>\n          {scrollTarget}\n        </TextTransition>\n      </div>\n      <ArrowDown\n        className={classnames(styles.arrow, {\n          [styles.up]: scrollTarget === \"top\",\n        })}\n      />\n    </button>\n  );\n};\n\nexport default ScrollButton;\n"
  },
  {
    "path": "client/src/components/ScrollButton.module.css",
    "content": ".ScrollButton {\n  background: transparent;\n  outline: none;\n  color: var(--color-white);\n  font-family: var(--font-family-primary);\n  position: fixed;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: space-between;\n  bottom: 9rem;\n  right: var(--number-right-placement);\n  width: 9.5rem;\n  height: 9.5rem;\n  border: 1px solid var(--color-red);\n  border-radius: 100%;\n  font-size: 2rem;\n  cursor: pointer;\n  transition: transform 0.7s ease-out 0.2s, right 0.3s ease-out;\n}\n\n.ScrollButton.hidden {\n  right: -10rem;\n}\n\n.ScrollText {\n  z-index: 1;\n}\n\n.arrow {\n  transition: all 0.5s ease-out;\n  margin-bottom: 14px;\n}\n\n.arrow.up {\n  transform: rotateZ(-180deg);\n}\n\n.ScrollButton:hover .arrow,\n.ScrollButton:hover,\n.ScrollButton:active {\n  transform: translateY(5px);\n}\n\n.ScrollButton:focus-visible {\n  outline: -webkit-focus-ring-color auto 1px;\n}\n\n.ScrollButton.up:hover .arrow {\n  transform: translateY(-5px) rotateZ(-180deg);\n}\n\n.ScrollButton.up:hover,\n.ScrollButton.up:active {\n  transform: translateY(-5px);\n}\n\n@media (max-width: 1750px) {\n  .ScrollButton {\n    right: 4vw;\n  }\n}\n\n@media (max-width: 1350px) {\n  .ScrollButton {\n    right: 10%;\n  }\n}\n\n@media (max-width: 750px), (orientation: landscape) AND (max-height: 650px) {\n  .ScrollButton {\n    justify-content: center;\n    width: 6.5rem;\n    height: 6.5rem;\n    bottom: 5%;\n    right: 4%;\n  }\n\n  .ScrollButton .ScrollText {\n    display: none;\n  }\n\n  .arrow {\n    margin: 0;\n  }\n\n  .arrow path {\n    stroke-width: 2px;\n  }\n}\n\n@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {\n  .ScrollButton {\n    justify-content: center;\n    width: 5rem;\n    height: 5rem;\n    bottom: 3%;\n    right: 5%;\n  }\n\n  .arrow {\n    width: 3rem;\n    height: 3rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Searchbox.jsx",
    "content": "import classnames from \"classnames\";\nimport { useState } from \"react\";\nimport styles from \"./Searchbox.module.css\";\nimport SearchIcon from \"../icons/SearchboxIcon.svg\";\n\n//Rewrite the Searchbox component below but without forwardRef\nconst Searchbox = ({\n  episodes,\n  setEpisodes,\n  allEpisodes,\n  shakeEpisodes,\n  searchText,\n  setSearchText,\n}) => {\n  const [placeholder, setPlaceholder] = useState(\"Search for episode or guest\");\n  // TODO: useeffect isteden som setter episode ved tab change\n  const handleSearch = (e) => {\n    setEpisodes(() => {\n      return allEpisodes.filter((ep) =>\n        ep.full_name?.toLowerCase().includes(e.target.value.toLowerCase())\n      );\n    });\n    setSearchText(e.target.value);\n  };\n\n  const classesSearchIcon = classnames(styles.SearchIcon, {\n    [styles.hoverCursor]: searchText,\n  });\n\n  return (\n    <>\n      <div className={styles.Searchbox}>\n        <input\n          value={searchText}\n          onChange={handleSearch}\n          type=\"text\"\n          id=\"search\"\n          placeholder={placeholder}\n          onFocus={() => setPlaceholder(null)}\n          onBlur={() => setPlaceholder(\"Search for episode or guest\")}\n          onKeyUp={(e) => {\n            if (e.key === \"Enter\") shakeEpisodes();\n          }}\n          spellCheck=\"false\"\n          autoComplete=\"off\"\n        />\n\n        <SearchIcon\n          className={classesSearchIcon}\n          title=\"search-icon\"\n          onClick={() => {\n            if (searchText) {\n              shakeEpisodes();\n\n              navigator.vibrate();\n            }\n          }}\n        />\n      </div>\n      {searchText && (\n        <p className={styles.searchResult}>\n          {episodes.length} result\n          {episodes.length != 1 && \"s\"} found\n        </p>\n      )}\n    </>\n  );\n};\n\nexport default Searchbox;\n"
  },
  {
    "path": "client/src/components/Searchbox.module.css",
    "content": ".Searchbox {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border: 1px solid var(--color-grey-secondary);\n  border-radius: 1.6rem;\n  padding: 0 1.5rem;\n  width: 23.3rem;\n  height: 4.6rem;\n  transition: background-color 0.35s;\n  position: relative;\n}\n\n.Searchbox:focus-within {\n  background-color: var(--color-black-secondary);\n}\n\ninput {\n  flex: 1;\n  background-color: transparent;\n  border: none;\n  font-size: var(--font-size-small);\n  outline: none;\n  color: var(--color-grey-secondary);\n  caret-color: var(--color-white);\n  font-family: var(--font-family-primary);\n}\n\ninput::-webkit-input-placeholder {\n  position: absolute;\n  color: var(--color-grey-secondary);\n  opacity: 1;\n  font-family: var(--font-family-primary);\n  font-size: 1.2rem;\n\n  transform: translateY(-12%);\n  cursor: text;\n  transition: all 0.15s ease-out;\n}\n\n.searchResult {\n  color: var(--color-grey-secondary);\n  font-size: 1.2rem;\n  margin-top: 1rem;\n  margin-left: 1.5rem;\n}\n\n.SearchIcon.hoverCursor {\n  cursor: pointer;\n}\n"
  },
  {
    "path": "client/src/components/Sort.jsx",
    "content": "import { useEffect } from \"react\";\nimport Arrow from \"../icons/arrow.svg\";\nimport { useState } from \"react\";\nimport classnames from \"classnames\";\nimport styles from \"./Sort.module.css\";\nimport Disclosure from \"./Disclosure\";\nimport Chavron from \"../icons/chavron.svg\";\n\nconst options = {\n  removed: [\"episode number\", \"date removed\"],\n  shortened: [\"episode number\", \"date shortened\"],\n};\n\nconst initialState = { name: \"episode number\", reverse: false };\n\nconst Sort = ({ setEpisodes, episodes, listShown }) => {\n  const [open, setOpen] = useState(false);\n  const [selected, setSelected] = useState(initialState);\n\n  useEffect(() => {\n    setSelected(initialState);\n  }, [listShown]);\n\n  const handleSort = (option, isReversed) => {\n    let nulls;\n    let nonNulls;\n    if (option === \"date shortened\") {\n      nulls = [];\n      nonNulls = nonNulls = episodes.sort((a, b) => {\n        [a, b] = isReversed ? [a, b] : [b, a];\n        return a.changes[0].date.ms - b.changes[0].date.ms;\n      });\n    } else if (option === \"date removed\") {\n      nulls = episodes.filter((ep) => !ep.date);\n      nonNulls = episodes\n        .filter((ep) => ep.date)\n        .sort((a, b) => {\n          [a, b] = isReversed ? [a, b] : [b, a];\n          return a.date.ms - b.date.ms;\n        });\n      // episode number\n    } else {\n      nulls = episodes.filter((ep) => !ep.episode_number);\n      nonNulls = episodes\n        .filter((ep) => ep.episode_number)\n        .sort((a, b) => {\n          [a, b] = isReversed ? [a, b] : [b, a];\n          return a.episode_number - b.episode_number;\n        });\n    }\n\n    setEpisodes([...nonNulls, ...nulls]);\n  };\n\n  const disclosureId = \"sort-by-toggle\";\n  const optionsWrapperId = \"sort-by-content\";\n\n  return (\n    <div\n      className={classnames(styles.sort, {\n        [styles.open]: open,\n      })}>\n      <Disclosure\n        className={styles.sortDisclosure}\n        isOpen={open}\n        onClick={() => setOpen((open) => !open)}\n        id={disclosureId}\n        ariaControls={optionsWrapperId}>\n        Sort by\n        <Chavron\n          className={classnames(styles.Chavron, {\n            [styles.open]: open,\n          })}\n        />\n      </Disclosure>\n      <div\n        role=\"listbox\"\n        className={styles.optionsWrapper}\n        id={optionsWrapperId}\n        aria-labelledby={disclosureId}\n        aria-expanded={open}>\n        {options[listShown]?.map((option) => {\n          return (\n            <Option\n              optionName={option}\n              key={option}\n              handleSort={handleSort}\n              selected={selected}\n              setSelected={setSelected}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nfunction Option({ optionName, selected, setSelected, handleSort }) {\n  const isSelected = selected.name === optionName;\n  const isReversed = isSelected && selected.reverse;\n  function handleClick() {\n    const newReverse = isSelected ? !isReversed : isReversed;\n    setSelected({ name: optionName, reverse: newReverse });\n    handleSort(optionName, newReverse);\n  }\n\n  return (\n    <button\n      role=\"option\"\n      aria-selected={isSelected}\n      aria-labelledby=\"option-label\"\n      className={classnames(styles.option, {\n        [styles.selected]: isSelected,\n      })}\n      onClick={handleClick}>\n      <div className={styles.label} id=\"option-label\">\n        {optionName\n          .split(\" \")\n          .map((word) => word[0].toUpperCase() + word.slice(1))\n          .join(\" \")}\n      </div>\n\n      <Arrow\n        className={classnames(styles.icon, {\n          [styles.iconReverse]: isReversed,\n        })}\n      />\n    </button>\n  );\n}\n\nexport default Sort;\n"
  },
  {
    "path": "client/src/components/Sort.module.css",
    "content": ".sort {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n  color: var(--color-grey-secondary);\n  margin-right: 2.5rem;\n  margin-top: 6rem;\n  font-size: var(--font-size-small);\n}\n\n.sortDisclosure {\n  gap: 3px;\n}\n\n.optionsWrapper {\n  display: flex;\n  flex-direction: column;\n  margin-top: 1.5rem;\n}\n\n.sort.open .optionsWrapper {\n  cursor: pointer;\n}\n\n.option {\n  font-size: 11px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  color: var(--color-grey-secondary);\n  padding: 0.3em 0.6em 0.3em 1em;\n  border: 1px solid var(--color-grey-secondary);\n  transition: color, border-color 0.25s;\n}\n\n.option {\n  visibility: hidden;\n}\n\n.sort.open .option {\n  visibility: visible;\n}\n\n.option .label {\n  margin-right: 2px;\n  transition: color 0.5s;\n}\n\n.option.selected {\n  border-color: var(--color-white);\n}\n.option:not(.selected) {\n  transition: background-color 0.5s;\n}\n\n.option:hover:not(.selected) {\n  background-color: var(--color-black-secondary);\n}\n\n.icon {\n  transform: scale(0.6);\n  transition: color, transform 0.25s ease-out;\n}\n\n.icon path {\n  color: var(--color-grey);\n  transition: color 0.5s;\n  transition-delay: 0.25;\n}\n\n.option.selected .label,\n.option.selected:hover .icon path {\n  color: var(--color-white);\n}\n\n.option.selected:hover .label {\n  color: var(--color-grey-secondary);\n}\n\n.iconReverse {\n  transform: rotate(180deg) scale(0.6);\n}\n\n.Chavron {\n  transform: rotate(180deg) scale(0.65);\n  margin-top: 3px;\n  transition: transform 0.25s ease-out;\n}\n\n.Chavron.open {\n  transform: rotate(0deg) scale(0.65);\n  margin-top: 3px;\n}\n\n@media (max-width: 1350px) {\n  .sort {\n    margin: 0;\n    justify-content: center;\n    align-items: center;\n  }\n\n  .sort.open {\n    margin-bottom: 4rem;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Sponsor.jsx",
    "content": "import styles from \"./Sponsor.module.css\";\n\nconst Sponsor = () => {\n  return (\n    <iframe\n      src=\"https://github.com/sponsors/henb13/button\"\n      title=\"Sponsor henb13\"\n      className={styles.githubSponsorButton}></iframe>\n  );\n};\n\nexport default Sponsor;\n"
  },
  {
    "path": "client/src/components/Sponsor.module.css",
    "content": ".githubSponsorButton {\n  position: fixed;\n  top: 3.55rem;\n  left: 20rem;\n  height: 35px;\n  width: 116px;\n  border: 0;\n  margin-left: 2rem;\n  cursor: pointer;\n}\n\n@media (max-width: 1350px) {\n  .githubSponsorButton {\n    left: 8rem;\n    position: absolute;\n  }\n}\n\n@media (max-width: 750px) {\n  .githubSponsorButton {\n    top: 1.6rem;\n    left: 5rem;\n  }\n}\n\n@media (max-width: 380px) {\n  .githubSponsorButton {\n    top: 1.5rem;\n    left: 0;\n  }\n}\n"
  },
  {
    "path": "client/src/components/Tag.jsx",
    "content": "import classnames from \"classnames\";\nimport { Tooltip } from \"react-tooltip\";\nimport styles from \"./Tag.module.css\";\n\nconst variantClasses = {\n  new: styles.new,\n  originalLength: styles.originalLength,\n};\n\nconst Tag = ({ className, variant, children, toolTip }) => (\n  <span className={classnames(styles.tag, className, variantClasses[variant])}>\n    <span className={styles.tagName}>{children}</span>\n    {toolTip && (\n      <>\n        <span\n          data-tooltip-id=\"my-tooltip\"\n          data-tooltip-content={toolTip}\n          className={styles.toolTip}>\n          &#63;\n        </span>\n        <Tooltip id=\"my-tooltip\" clickable className={styles.toolTipElement} />\n      </>\n    )}\n  </span>\n);\n\nexport default Tag;\n"
  },
  {
    "path": "client/src/components/Tag.module.css",
    "content": ".tag {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  font-size: 11px;\n  color: var(--color-black);\n  font-weight: 700;\n  line-height: 100%;\n  width: min-content;\n}\n\n.tagName {\n  white-space: nowrap;\n  border-radius: 10px;\n  padding: 2px 8px 4px;\n}\n\n.new .tagName {\n  background-color: var(--color-green);\n}\n\n.originalLength .tagName {\n  background-color: var(--color-orange);\n}\n\n.toolTip {\n  font-size: 10px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 100%;\n  background-color: transparent;\n  border: 1px solid;\n  width: 2px;\n  height: 2px;\n  padding: 7px;\n  cursor: pointer;\n  margin-left: 6px;\n  margin-bottom: 1px;\n  color: var(--color-grey);\n  border-color: var(--color-grey);\n}\n\n.toolTipElement {\n  max-width: 250px;\n  font-family: var(--font-family-secondary);\n  line-height: 16px;\n  font-weight: normal;\n  opacity: 0.95 !important;\n}\n"
  },
  {
    "path": "client/src/hooks/useFetch.js",
    "content": "import { useState, useEffect } from \"react\";\n\nconst useFetch = (url) => {\n  const [data, setData] = useState(null);\n  const [isPending, setIsPending] = useState(true);\n\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    const abortCont = new AbortController();\n    fetch(url, { signal: abortCont.signal })\n      .then((res) => {\n        if (!res.ok) {\n          throw Error(\n            res.status == 429\n              ? \"You are making too many requests, please try again later.\"\n              : \"Something went wrong, please try again later.\"\n          );\n        }\n        return res.json();\n      })\n      .then((data) => {\n        setIsPending(false);\n        setError(null);\n        setData(data);\n      })\n      .catch((err) => {\n        if (err.name === \"AbortError\") {\n          console.warn(\"fetch aborted\");\n        } else {\n          setIsPending(false);\n          console.error(`Something went wrong with fetching episodes: ${err}`);\n          setError(err.message);\n        }\n      });\n\n    return () => abortCont.abort();\n  }, [url]);\n\n  return { data, isPending, error };\n};\n\nexport default useFetch;\n"
  },
  {
    "path": "client/src/hooks/useMinLoadingTime.js",
    "content": "import { useState, useEffect } from \"react\";\n\nconst useMinLoadingTime = (minTime) => {\n  const [minLoadingTimeElapsed, setMinLoadingTimeElapsed] = useState(true);\n\n  useEffect(() => {\n    setMinLoadingTimeElapsed(false);\n\n    const timer = setTimeout(() => {\n      setMinLoadingTimeElapsed(true);\n    }, minTime);\n\n    return () => {\n      clearTimeout(timer);\n    };\n  }, [minTime]);\n\n  return minLoadingTimeElapsed;\n};\n\nexport default useMinLoadingTime;\n"
  },
  {
    "path": "client/src/hooks/useScroll.js",
    "content": "import { useState, useEffect } from \"react\";\nimport _ from \"lodash\";\n\nconst useScroll = ({\n  refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],\n}) => {\n  const [scrollTarget, setScrollTarget] = useState(\"bottom\");\n  const [scrollable, setScrollable] = useState(null);\n\n  useEffect(() => {\n    setScrollable(document.body.clientHeight > window.innerHeight);\n  }, [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText, setScrollable]);\n\n  useEffect(() => {\n    const handleScroll = _.throttle(() => {\n      setScrollTarget(\n        window.pageYOffset + window.innerHeight / 2 > document.body.clientHeight / 2\n          ? \"top\"\n          : \"bottom\"\n      );\n    }, 200);\n\n    const handleResize = _.throttle(() => {\n      handleScroll();\n      setScrollable(document.body.clientHeight > window.innerHeight);\n    }, 200);\n\n    window.addEventListener(\"scroll\", handleScroll, {\n      passive: true,\n    });\n    window.addEventListener(\"resize\", handleResize, {\n      passive: true,\n    });\n\n    return () => {\n      window.removeEventListener(\"scroll\", handleScroll);\n      window.removeEventListener(\"resize\", handleResize);\n    };\n  }, [scrollTarget]);\n\n  return { scrollTarget, scrollable, setScrollable };\n};\n\nexport default useScroll;\n"
  },
  {
    "path": "client/src/index.css",
    "content": "@import url(\"https://fonts.googleapis.com/css2?family=Oswald:wght@200;300;400;500;600;700&display=swap\");\n@import url(\"https://fonts.googleapis.com/css2?family=Teko:wght@700&display=swap\");\n\n:root {\n  --color-black-secondary: #0f0f0f;\n  --color-black: #151515;\n  --color-grey: #424242;\n  --color-grey-secondary: #6b6b6b;\n  --color-white: #fafafa;\n  --color-white-secondary: #e8e6e6;\n  --color-orange: #ee7d2c;\n  --color-red: #a51f21;\n  --color-green: #1ed760;\n\n  --font-size-large: 2.2rem;\n  --font-size-medium: 1.6rem;\n  --font-size-small: 1.2rem;\n  --font-size-xs: 1rem;\n\n  --font-family-primary: Oswald, sans-serif;\n  --font-family-secondary: Roboto, sans-serif;\n\n  --number-right-placement: 6%;\n}\n\n*,\n:after,\n:before {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n}\n\nhtml {\n  font-size: 62.5%;\n  font-family: var(--font-family-primary);\n  overflow-y: scroll;\n}\n\nbody {\n  background-color: var(--color-black);\n  color: var(--color-white);\n  min-height: 100vh;\n}\n\na,\na:link,\na:visited {\n  color: inherit;\n  text-decoration: none;\n}\n\ninput,\ntextarea,\nbutton,\nselect,\na {\n  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\ninput,\nbutton,\ntextarea,\nselect {\n  font: inherit;\n}\n\nbutton {\n  background: none;\n  color: inherit;\n  border: none;\n  padding: 0;\n  font: inherit;\n  cursor: pointer;\n  outline: inherit;\n}\n\nbutton:hover {\n  cursor: pointer;\n}\n\nbutton:focus-visible {\n  outline: -webkit-focus-ring-color auto 2px;\n}\n\n.shake {\n  animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;\n}\n\n@keyframes shake {\n  10%,\n  90% {\n    transform: translate3d(-1px, 0, 0);\n  }\n\n  20%,\n  80% {\n    transform: translate3d(2px, 0, 0);\n  }\n\n  30%,\n  50%,\n  70% {\n    transform: translate3d(-4px, 0, 0);\n  }\n\n  40%,\n  60% {\n    transform: translate3d(4px, 0, 0);\n  }\n}\n"
  },
  {
    "path": "client/src/index.jsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport \"./index.css\";\nimport App from \"./components/App\";\n\nconst root = ReactDOM.createRoot(document.getElementById(\"root\"));\nroot.render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "client/src/skeletons/SkeletonList.jsx",
    "content": "import { useRef } from \"react\";\nimport styles from \"./SkeletonStyles.module.css\";\n\nconst SkeletonList = () => {\n  const stylesArr = useRef(\n    Array.apply(null, Array(42)).map(() => {\n      const random = Math.random();\n      const type =\n        random > 90\n          ? styles.extraLarge\n          : random > 0.85\n          ? styles.large\n          : random > 0.75\n          ? styles.medium\n          : random > 0.15\n          ? styles.small\n          : styles.extraSmall;\n\n      return type;\n    })\n  );\n\n  return (\n    <div className={`${styles.SkeletonList}`}>\n      {stylesArr.current?.map((st, i) => {\n        return (\n          <div key={i} className={`${styles.SkeletonElement} ${styles.listElement} ${st}`}>\n            <div className={styles.SkeletonShimmerWrapper}>\n              <div className={styles.SkeletonShimmer}></div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n\nexport default SkeletonList;\n"
  },
  {
    "path": "client/src/skeletons/SkeletonStyles.module.css",
    "content": ".SkeletonList {\n  position: unset;\n  font-size: 1.4rem;\n  list-style-type: none;\n  display: flex;\n  flex-direction: column;\n  gap: 4.5rem;\n  margin-top: 6.5rem;\n}\n\n.SkeletonElement {\n  position: relative;\n  overflow: hidden;\n  background-color: #212121;\n  border-radius: 2px;\n}\n\n.hidden {\n  visibility: hidden;\n}\n\n.listElement {\n  height: 20px;\n}\n\n.SkeletonElement.extraSmall {\n  width: 105px;\n}\n\n.SkeletonElement.small {\n  width: 150px;\n}\n\n.SkeletonElement.medium {\n  width: 220px;\n}\n\n.SkeletonElement.large {\n  width: 260px;\n}\n\n.SkeletonElement.extraLarge {\n  width: 325px;\n}\n\n.SkeletonShimmerWrapper {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n}\n\n.SkeletonShimmer {\n  width: 50%;\n  height: 100%;\n  background: rgba(0, 0, 0, 0.1);\n  transform: skewX(-50deg);\n  box-shadow: 0 0 60px 60px rgba(0, 0, 0, 0.1);\n  animation: loading 2s infinite;\n}\n\n@keyframes loading {\n  0% {\n    transform: translateX(-20vw);\n  }\n  50% {\n    transform: translateX(3vw);\n  }\n  100% {\n    transform: translateX(20vw);\n  }\n}\n\n.skeletonText {\n  display: flex;\n  flex-direction: column;\n  margin-top: 10.5rem;\n  margin-bottom: 2.5rem;\n}\n\n.amount {\n  position: relative;\n  height: 24px;\n  width: 280px;\n}\n\n.shortened {\n  margin-top: 10px;\n  height: 22px;\n  width: 240px;\n}\n\n.lastChecked {\n  position: relative;\n  margin-top: 7px;\n  height: 19px;\n  width: 250px;\n}\n\n@media (max-width: 1350px) {\n  .SkeletonList {\n    justify-content: center;\n    align-items: center;\n    margin-top: 0;\n    gap: 9.15rem;\n  }\n\n  .SkeletonElement.extraSmall,\n  .SkeletonElement.small,\n  .SkeletonElement.medium,\n  .SkeletonElement.large,\n  .SkeletonElement.extraLarge {\n    width: 130px;\n    height: 25px;\n  }\n\n  .skeletonText {\n    margin-top: 4.7rem;\n    align-items: center;\n    margin-bottom: 48px;\n  }\n\n  .amount {\n    width: 250px;\n    height: 22px;\n    margin-left: 2px;\n  }\n\n  .shortened {\n    width: 220px;\n    margin-top: 7px;\n    height: 21px;\n  }\n\n  .lastChecked {\n    height: 18px;\n    width: 150px;\n    margin-top: 10px;\n  }\n}\n"
  },
  {
    "path": "client/src/skeletons/SkeletonText.jsx",
    "content": "import classnames from \"classnames\";\nimport styles from \"./SkeletonStyles.module.css\";\n\nconst SkeletonElement = ({ elStyle }) => {\n  return (\n    <div className={`${styles.SkeletonElement} ${elStyle}`}>\n      <div className={styles.SkeletonShimmerWrapper}>\n        <div className={styles.SkeletonShimmer}></div>\n      </div>\n    </div>\n  );\n};\n\nconst SkeletonText = () => {\n  return (\n    <div className={styles.skeletonText}>\n      <SkeletonElement elStyle={styles.amount} />\n      <SkeletonElement elStyle={classnames(styles.amount, styles.shortened)} />\n      <SkeletonElement elStyle={styles.lastChecked} />\n    </div>\n  );\n};\n\nexport default SkeletonText;\n"
  },
  {
    "path": "client/src/utils.js",
    "content": "import { zonedTimeToUtc, utcToZonedTime, format as formatTz } from \"date-fns-tz\";\n\nexport const getClientLocalTime = (date, pattern) => {\n  const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n  const utcDate = zonedTimeToUtc(date, userTimezone);\n  const zonedDate = utcToZonedTime(utcDate, userTimezone);\n  const lastCheckedDate = formatTz(zonedDate, pattern, {\n    timeZone: userTimezone,\n  });\n  return lastCheckedDate;\n};\n\nexport const getDateString = (time) => {\n  return getClientLocalTime(time, \"PPP\");\n};\n\nexport const formatMinutesToTimeAmountString = (minutes) => {\n  if (minutes < 1) {\n    return \"less than a minute ago\";\n  }\n\n  if (minutes < 60) {\n    const minutesString = setPlurality(\"minute\", minutes);\n    return `${minutes} ${minutesString}`;\n  }\n  if (minutes < 1440) {\n    const hours = Math.floor(minutes / 60);\n    const hoursString = setPlurality(\"hour\", hours);\n    const minutesRest = minutes % 60;\n    const minutesString = setPlurality(\"minute\", minutesRest);\n\n    return `${hours} ${hoursString}${\n      minutesRest === 0 ? \"\" : ` and ${minutesRest} ${minutesString}`\n    }`;\n  }\n\n  const days = Math.floor(minutes / (60 * 24));\n  const daysString = days === 1 ? \"day\" : \"days\";\n  return `${days} ${daysString}`;\n};\n\nconst setPlurality = (text, number) => {\n  return `${text}${number === 1 ? \"\" : \"s\"}`;\n};\n"
  },
  {
    "path": "client/src/utils.test.js",
    "content": "import { formatMinutesToTimeAmountString } from \"./utils\";\n\ndescribe(\"get correct timestring given a minute amount as input\", () => {\n  test(\"return 'less than a minute ago'\", () => {\n    expect(formatMinutesToTimeAmountString(0)).toBe(\"less than a minute ago\");\n    expect(formatMinutesToTimeAmountString(0.5)).toBe(\"less than a minute ago\");\n  });\n\n  test(\"return minutes\", () => {\n    expect(formatMinutesToTimeAmountString(1)).toBe(\"1 minute\");\n    expect(formatMinutesToTimeAmountString(30)).toBe(\"30 minutes\");\n    expect(formatMinutesToTimeAmountString(59)).toBe(\"59 minutes\");\n  });\n\n  test(\"return hours\", () => {\n    expect(formatMinutesToTimeAmountString(60)).toBe(\"1 hour\");\n    expect(formatMinutesToTimeAmountString(60 * 2)).toBe(\"2 hours\");\n    expect(formatMinutesToTimeAmountString(60 * 23)).toBe(\"23 hours\");\n  });\n\n  test(\"return days\", () => {\n    expect(formatMinutesToTimeAmountString(60 * 24)).toBe(\"1 day\");\n    expect(formatMinutesToTimeAmountString(60 * 24 * 2)).toBe(\"2 days\");\n    expect(formatMinutesToTimeAmountString(60 * 24 * 3)).toBe(\"3 days\");\n    expect(formatMinutesToTimeAmountString(60 * 24 * 76)).toBe(\"76 days\");\n  });\n});\n\ndescribe(\"get correct minutes addition to time string when returning hours (i.e. '2 hours and 5 minutes')\", () => {\n  test(\"return hours and minutes\", () => {\n    expect(formatMinutesToTimeAmountString(60 + 1)).toBe(\"1 hour and 1 minute\");\n    expect(formatMinutesToTimeAmountString(60 + 59)).toBe(\"1 hour and 59 minutes\");\n    expect(formatMinutesToTimeAmountString(60 * 2 + 5)).toBe(\"2 hours and 5 minutes\");\n    expect(formatMinutesToTimeAmountString(60 * 5 + 25)).toBe(\"5 hours and 25 minutes\");\n    expect(formatMinutesToTimeAmountString(60 * 6 + 1)).toBe(\"6 hours and 1 minute\");\n    expect(formatMinutesToTimeAmountString(60 * 6 + 2)).toBe(\"6 hours and 2 minutes\");\n  });\n});\n"
  },
  {
    "path": "client/tsconfig.json",
    "content": "{\r\n  \"compilerOptions\": {\r\n    \"target\": \"ESNext\",\r\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\r\n    \"allowJs\": true,\r\n    \"skipLibCheck\": true,\r\n    \"esModuleInterop\": true,\r\n    \"allowSyntheticDefaultImports\": true,\r\n    \"strict\": true,\r\n    \"forceConsistentCasingInFileNames\": true,\r\n    \"module\": \"esnext\",\r\n    \"moduleResolution\": \"node\",\r\n    \"resolveJsonModule\": true,\r\n    \"isolatedModules\": true,\r\n    \"noEmit\": true,\r\n    \"noFallthroughCasesInSwitch\": true,\r\n    \"jsx\": \"react-jsx\",\r\n    \"types\": [\"vite/client\", \"vite-plugin-svgr/client\"]\r\n  },\r\n  \"include\": [\"src\"]\r\n}\r\n"
  },
  {
    "path": "client/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" /> /// <referencetypes=\"vite-plugin-svgr/client\" />\r\n"
  },
  {
    "path": "client/vite.config.ts",
    "content": "import { defineConfig } from \"vitest/config\";\r\nimport react from \"@vitejs/plugin-react-swc\";\r\nimport tsconfigPaths from \"vite-tsconfig-paths\";\r\nimport svgrPlugin from \"vite-plugin-svgr\";\r\n\r\n// https://vitejs.dev/config/\r\nexport default defineConfig({\r\n  base: \"/\",\r\n  plugins: [\r\n    react(),\r\n    tsconfigPaths(),\r\n    svgrPlugin({ svgrOptions: { icon: true }, include: \"**/*.svg\" }),\r\n  ],\r\n  server: {\r\n    port: 3000,\r\n  },\r\n  test: {\r\n    globals: true,\r\n    environment: \"jsdom\",\r\n    css: true,\r\n    reporters: [\"verbose\"],\r\n    coverage: {\r\n      reporter: [\"text\", \"json\", \"html\"],\r\n      include: [\"src/**/*\"],\r\n      exclude: [],\r\n    },\r\n  },\r\n});\r\n"
  },
  {
    "path": "server/.eslintrc.json",
    "content": "{\n  \"env\": {\n    \"commonjs\": true,\n    \"node\": true,\n    \"es2021\": true,\n    \"jest\": true\n  },\n  \"extends\": [\"eslint:recommended\", \"prettier\"],\n  \"parserOptions\": {\n    \"ecmaVersion\": 12\n  },\n  \"rules\": {\n    \"prefer-const\": [\n      \"warn\",\n      {\n        \"destructuring\": \"all\",\n        \"ignoreReadBeforeAssign\": false\n      }\n    ],\n    \"prefer-template\": \"warn\"\n  },\n  \"ignorePatterns\": [\"node_modules/\", \"coverage/\"]\n}\n"
  },
  {
    "path": "server/api/__mocks__/mockResponse.js",
    "content": "const missingEpisodes = [\n  {\n    full_name: \"#374 - Marc Maron\",\n    episode_number: 374,\n    isNew: false,\n    date: {\n      ms: 1643984238000,\n      formatted: \"February 4th, 2022\",\n      htmlAttribute: \"2022-02-04\",\n    },\n  },\n  {\n    full_name: \"#320 - Tim Ferriss\",\n    episode_number: 320,\n    isNew: false,\n    date: {\n      ms: 1644006629000,\n      formatted: \"February 4th, 2022\",\n      htmlAttribute: \"2022-02-04\",\n    },\n  },\n];\n\nconst shortenedEpisodes = [\n  {\n    id: 2124,\n    episode_number: 1877,\n    full_name: \"#1877 - Jann Wenner\",\n    isNew: true,\n    isOriginalLength: false,\n    changes: [\n      {\n        date: {\n          ms: 1665010388000,\n          formatted: \"October 6th, 2022\",\n          htmlAttribute: \"2022-10-06\",\n        },\n        new_duration_string: \"2 hr 51 min 39 sec\",\n        old_duration_string: \"2 hr 51 min 46 sec\",\n      },\n    ],\n  },\n  {\n    id: 1943,\n    episode_number: 1330,\n    full_name: \"#1330 - Bernie Sanders\",\n    isNew: false,\n    isOriginalLength: false,\n    changes: [\n      {\n        date: {\n          ms: 1664486810000,\n          formatted: \"September 29th, 2022\",\n          htmlAttribute: \"2022-09-29\",\n        },\n        new_duration_string: \"1 hr 17 min 9 sec\",\n        old_duration_string: \"1 hr 51 min 46 sec\",\n      },\n    ],\n  },\n  {\n    id: 1389,\n    episode_number: 1159,\n    full_name: \"#1159 - Neil deGrasse Tyson\",\n    isNew: true,\n    isOriginalLength: true,\n    changes: [\n      {\n        date: {\n          ms: 1665005849000,\n          formatted: \"October 5th, 2022\",\n          htmlAttribute: \"2022-10-05\",\n        },\n        new_duration_string: \"3 hr 21 min 8 sec\",\n        old_duration_string: \"3 hr 21 min 3 sec\",\n      },\n      {\n        date: {\n          ms: 1665005755000,\n          formatted: \"October 5th, 2022\",\n          htmlAttribute: \"2022-10-05\",\n        },\n        new_duration_string: \"3 hr 21 min 3 sec\",\n        old_duration_string: \"3 hr 21 min 8 sec\",\n      },\n      {\n        date: {\n          ms: 1664919458000,\n          formatted: \"October 4th, 2022\",\n          htmlAttribute: \"2022-10-04\",\n        },\n        new_duration_string: \"3 hr 21 min 6 sec\",\n        old_duration_string: \"3 hr 21 min 7 sec\",\n      },\n      {\n        date: {\n          ms: 1664919253000,\n          formatted: \"October 4th, 2022\",\n          htmlAttribute: \"2022-10-04\",\n        },\n        new_duration_string: \"3 hr 21 min 7 sec\",\n        old_duration_string: \"3 hr 21 min 8 sec\",\n      },\n    ],\n  },\n  {\n    id: 1340,\n    episode_number: 1678,\n    full_name: \"#1678 - Michael Pollen\",\n    isNew: true,\n    isOriginalLength: false,\n    changes: [\n      {\n        date: {\n          ms: 1665005849000,\n          formatted: \"October 5th, 2022\",\n          htmlAttribute: \"2022-10-05\",\n        },\n        new_duration_string: \"3 hr 21 min 1 sec\",\n        old_duration_string: \"3 hr 21 min 3 sec\",\n      },\n      {\n        date: {\n          ms: 1665005755000,\n          formatted: \"October 5th, 2022\",\n          htmlAttribute: \"2022-10-05\",\n        },\n        new_duration_string: \"3 hr 21 min 3 sec\",\n        old_duration_string: \"3 hr 21 min 8 sec\",\n      },\n      {\n        date: {\n          ms: 1664919458000,\n          formatted: \"October 4th, 2022\",\n          htmlAttribute: \"2022-10-04\",\n        },\n        new_duration_string: \"3 hr 21 min 6 sec\",\n        old_duration_string: \"3 hr 21 min 7 sec\",\n      },\n      {\n        date: {\n          ms: 1664919253000,\n          formatted: \"October 4th, 2022\",\n          htmlAttribute: \"2022-10-04\",\n        },\n        new_duration_string: \"3 hr 21 min 7 sec\",\n        old_duration_string: \"3 hr 21 min 8 sec\",\n      },\n    ],\n  },\n];\n\nconst lastCheckedInMs = 1665691453175;\n\nconst mockResponse = {\n  missingEpisodes,\n  shortenedEpisodes,\n  lastCheckedInMs,\n};\n\nmodule.exports = { mockResponse };\n"
  },
  {
    "path": "server/api/dev-api.js",
    "content": "const express = require(\"express\");\r\nconst router = express.Router();\r\nconst { mockResponse } = require(\"./__mocks__/mockResponse\");\r\n\r\nrouter.use(express.json());\r\nrequire(\"dotenv\").config();\r\n\r\nrouter.get(\"/api/episodes\", async (_, res) => {\r\n  res.header({\r\n    \"Access-Control-Allow-Origin\": \"http://localhost:3000\",\r\n  });\r\n\r\n  return res.json(mockResponse);\r\n\r\n  //TODO: Implement dev database\r\n});\r\n\r\nmodule.exports = router;\r\n"
  },
  {
    "path": "server/api/index.js",
    "content": "const express = require(\"express\");\nconst router = express.Router();\nconst DB = require(\"../db/db\");\nconst pool = require(\"../db/connect\");\nconst { mockResponse } = require(\"./__mocks__/mockResponse\");\n\nrouter.use(express.json());\nrequire(\"dotenv\").config();\n\nlet missingEpisodesCache;\nlet shortenedEpisodesCache;\nlet lastCheckedCache;\n\nconst { CRON_INTERVAL, USE_MOCK_DATA, NODE_ENV } = process.env;\nconst isDev = NODE_ENV === \"development\";\n\n//TODO: Change when client in prod\nconst allowOrigin = isDev ? \"http://localhost:3000\" : \"https://not-yet-prod-domain.com\";\n\nrouter.get(\"/api/episodes\", async (_, res) => {\n  if (isDev && USE_MOCK_DATA === \"true\") {\n    return res.json(mockResponse);\n  }\n\n  const timeSinceLastCheckedDbInMins =\n    lastCheckedCache && (Date.now() - lastCheckedCache) / 1000 / 60;\n\n  const cacheTimeLeftSecs = Math.floor((CRON_INTERVAL - timeSinceLastCheckedDbInMins) * 60);\n  const buffer = 120;\n\n  res.header({\n    \"cache-control\": `no-transform, max-age=${cacheTimeLeftSecs + buffer || 1}`,\n    \"Access-Control-Allow-Origin\": allowOrigin,\n  });\n\n  if (\n    !missingEpisodesCache ||\n    !shortenedEpisodesCache ||\n    timeSinceLastCheckedDbInMins > CRON_INTERVAL\n  ) {\n    await (async () => {\n      const client = await pool.connect();\n      const db = DB(client);\n      try {\n        missingEpisodesCache = await db.getMissingEpisodes();\n        shortenedEpisodesCache = await db.getShortenedEpisodes();\n        lastCheckedCache = await db.getLastChecked();\n        console.info(\"db queried and cache updated\");\n      } finally {\n        client.release();\n      }\n    })().catch((err) => console.error(err.message));\n  }\n\n  console.info(\"request fired\");\n  res.json({\n    missingEpisodes: missingEpisodesCache,\n    shortenedEpisodes: shortenedEpisodesCache,\n    lastCheckedInMs: lastCheckedCache,\n  });\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "server/app.js",
    "content": "const express = require(\"express\");\nconst app = express();\nconst api = require(\"./api/index.js\");\nconst devApi = require(\"./api/dev-api.js\");\nconst rateLimit = require(\"express-rate-limit\");\nconst slowDown = require(\"express-slow-down\");\nconst helmet = require(\"helmet\");\nrequire(\"dotenv\").config();\n\n// eslint-disable-next-line no-undef\nconst port = process.env.PORT || 3001;\nconst useDevApi =\n  process.env.NODE_ENV === \"development\" && process.env.USE_MOCK_DATA === \"true\";\n\napp.set(\"trust proxy\", 1);\n\nconst rateLimiter = rateLimit({\n  windowMs: 15 * 1000,\n  max: 7,\n});\n\nconst speedLimiter = slowDown({\n  windowMs: 15 * 1000,\n  delayAfter: 3,\n  delayMs: (hits) => hits * 100,\n});\n\napp.use(express.json());\napp.use(helmet());\napp.use(rateLimiter);\napp.use(speedLimiter);\napp.use(useDevApi ? devApi : api);\n\napp.listen(port, () => {\n  console.log(`Server listening on port ${port}!`);\n});\n\nmodule.exports = app;\n"
  },
  {
    "path": "server/db/connect.js",
    "content": "const pg = require(\"pg\");\npg.types.setTypeParser(1184, (str) => str);\npg.defaults.poolSize = 25;\n\nrequire(\"dotenv\").config();\n\nconst pool = new pg.Pool();\n\nmodule.exports = pool;\n"
  },
  {
    "path": "server/db/db.js",
    "content": "const { mapMissingEpisodes, mapShortenedEpisodes, mapLastChecked } = require(\"./mapQueries\");\n\nconst getEpisodeNumber = require(\"../lib/getEpisodeNumber\");\n\nconst DB = (client) => {\n  return {\n    getAllEpisodes: async function () {\n      const { rows } = await client.query(\"SELECT * from all_eps\");\n      return rows;\n    },\n    getMissingEpisodes: async function () {\n      const { rows } = await client.query(\n        `SELECT full_name, EXTRACT(EPOCH FROM date_removed at time zone 'UTC') * 1000 AS date_removed, episode_number \n        FROM all_eps  \n        LEFT JOIN (\n          SELECT id, MAX(date_removed) AS date_removed\n          from date_removed \n          group by id\n        ) AS t2\n        ON all_eps.id = t2.id \n        WHERE on_spotify = false\n        ORDER BY episode_number DESC NULLS LAST, all_eps.id`\n      );\n      return mapMissingEpisodes(rows);\n    },\n    getShortenedEpisodes: async function () {\n      const { rows } = await client.query(\n        `SELECT all_eps.id AS id, episode_number, full_name, EXTRACT(EPOCH FROM date_changed at time zone 'UTC') * 1000 AS date_changed, new_duration, old_duration\n        FROM all_eps\n        JOIN (\n          SELECT id, episode_id, new_duration, old_duration, date AS date_changed\n          FROM duration_changes\n          GROUP BY episode_id, id, old_duration\n         ) AS t2\n         ON all_eps.id = t2.episode_id\n         ORDER BY date_changed DESC`\n      );\n      return mapShortenedEpisodes(rows);\n    },\n    insertNewEpisode: async function (episode) {\n      const epNumber = getEpisodeNumber(episode.name);\n      await client.query(\"INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)\", [\n        epNumber,\n        episode.name,\n        true,\n        episode.duration,\n      ]);\n    },\n    updateEpisodeName: async function (name, id) {\n      await client.query(\"UPDATE all_eps SET full_name=($1) WHERE id=($2)\", [name, id]);\n    },\n    updateEpisodeDuration: async function (newDuration, id) {\n      await client.query(\"UPDATE all_eps SET duration=($1) WHERE id=($2)\", [newDuration, id]);\n    },\n    setSpotifyStatus: async function ({ id }, bool) {\n      await client.query(`UPDATE all_eps SET on_spotify=($1) WHERE id=($2)`, [bool, id]);\n    },\n    setLastCheckedNow: async function () {\n      await client.query(\"UPDATE all_eps_log SET last_checked=now()\");\n    },\n    getLastChecked: async function () {\n      const { rows } = await client.query(\n        `SELECT EXTRACT(EPOCH FROM last_checked at time zone 'UTC') * 1000 AS miliseconds \n        FROM all_eps_log`\n      );\n      return mapLastChecked(rows);\n    },\n  };\n};\n\nmodule.exports = DB;\n"
  },
  {
    "path": "server/db/mapQueries.js",
    "content": "const { differenceInDays, parseISO } = require(\"date-fns\");\nconst {\n  formatMsToTimeString,\n  getDateString,\n  getDateTimeHTMLAttribute,\n} = require(\"../utils/utils\");\n\nconst DAYS_THRESHOLD_NEW = 14;\n\nconst getIsEpisodeNewlyReleased = (time) =>\n  time &&\n  differenceInDays(new Date(), parseISO(new Date(time).toISOString())) < DAYS_THRESHOLD_NEW;\n\nconst mapMissingEpisodes = (missingEpisodes) => {\n  return missingEpisodes.map((ep) => {\n    const { full_name, episode_number, date_removed } = ep;\n    const ms = parseInt(date_removed);\n\n    return {\n      full_name,\n      episode_number,\n      isNew: getIsEpisodeNewlyReleased(ms),\n      date: ms\n        ? {\n            ms,\n            formatted: getDateString(ms),\n            htmlAttribute: getDateTimeHTMLAttribute(ms),\n          }\n        : null,\n    };\n  });\n};\n\nconst mapShortenedEpisodes = (shortenedEpisodes) => {\n  return shortenedEpisodes\n    .reduce((acc, curr) => {\n      const { id, episode_number, full_name, date_changed, new_duration, old_duration } = curr;\n\n      const ms = parseInt(date_changed);\n\n      const changeItem = {\n        date: {\n          ms,\n          formatted: getDateString(ms),\n          htmlAttribute: getDateTimeHTMLAttribute(ms),\n        },\n        new_duration_string: formatMsToTimeString(new_duration),\n        old_duration_string: formatMsToTimeString(old_duration),\n        old_duration: parseInt(old_duration),\n        new_duration: parseInt(new_duration),\n      };\n\n      const index = acc.findIndex((item) => item.id === curr.id);\n\n      if (index === -1) {\n        acc.push({\n          id,\n          episode_number,\n          full_name,\n          isNew: getIsEpisodeNewlyReleased(ms),\n          changes: [changeItem],\n        });\n      } else {\n        acc[index].changes.push(changeItem);\n      }\n      return acc;\n    }, [])\n    .filter((episode) =>\n      episode.changes.some((change) => change.new_duration < change.old_duration)\n    )\n    .map((episode) => {\n      const { changes } = episode;\n      const oldestChange = changes[changes.length - 1];\n      const newestChange = changes[0];\n      const isOriginalLength =\n        oldestChange.old_duration_string === newestChange.new_duration_string;\n\n      const episodeWithoutUnusedProperties = {\n        ...episode,\n        changes: episode.changes.map(({ date, new_duration_string, old_duration_string }) => ({\n          date,\n          new_duration_string,\n          old_duration_string,\n        })),\n      };\n\n      return {\n        ...episodeWithoutUnusedProperties,\n        isOriginalLength,\n      };\n    });\n};\n\nconst mapLastChecked = (rows) => {\n  return parseInt(rows[0]?.miliseconds);\n};\n\nmodule.exports = {\n  mapMissingEpisodes,\n  mapShortenedEpisodes,\n  mapLastChecked,\n};\n"
  },
  {
    "path": "server/db/migration/missingEpisodesPerNow.js",
    "content": "const missingEpisodesPerNow = [\n  \"#1458 - Chris D’Elia\",\n  \"#1255 - Alex Jones Returns\",\n  \"#1218 - Gad Saad\",\n  \"#1206 - Mike Ward & Pantelis\",\n  \"#1197 - Michael Malice\",\n  \"#1187 - Kyle Kulinski\",\n  \"#1141 - Theo Von\",\n  \"#1103 - Tom Segura\",\n  \"#1093 - Owen Benjamin, Kurt Metzger\",\n  \"#1036 - Ari Shaffir, Bert Kreischer & Tom Segura\",\n  \"#1033 - Owen Benjamin\",\n  \"#998 - Owen Benjamin\",\n  \"#980 - Chris D’Elia\",\n  \"#979 - Sargon of Akkad\",\n  \"#963 - Michael Malice\",\n  \"#925 - Theo Von\",\n  \"#920 - Gavin McInnes\",\n  \"#912 - Pete Holmes\",\n  \"#911 - Alex Jones, Eddie Bravo\",\n  \"#820 - Milo Yiannopoulos\",\n  \"#810 - Big Jay Oakerson\",\n  \"#750 - Kip Andersen, Keegan Kuhn, producers of Conspiracy\",\n  \"#746 - TJ Kirk\",\n  \"#742 - Aubrey Marcus\",\n  \"#710 - Gavin McInnes\",\n  \"#702 - Milo Yiannopoulos\",\n  \"#674 - Brian Redban\",\n  \"#664 - Tom Segura & Christina Pazsitzky\",\n  \"#640 - Charles C. Johnson\",\n  \"#594 - Russell Peters\",\n  \"#582 - David Seaman\",\n  \"#570 - Ryan Parsons\",\n  \"#538 - Stefan Molyneux\",\n  \"#537 - Rich Vos\",\n  \"#533 - Chris D’elia\",\n  \"#525 - Bert Kreischer\",\n  \"#520 - David Seaman\",\n  \"#512 - Dan Savage\",\n  \"#506 - Moshe Kasher\",\n  \"#488 - Iliza Shlesinger\",\n  \"#487 - David Seaman\",\n  \"#463 - Louis Theroux\",\n  \"#461 - David Seaman\",\n  \"#454 - War Machine\",\n  \"#443 - Neal Brennan\",\n  \"#441 - Brian Dunning\",\n  \"#411 - Dave Asprey\",\n  \"#375 - Shane Smith\",\n  \"#374 - Marc Maron\",\n  \"#368 - David Seaman\",\n  \"#361 - Dave Asprey, Tait Fletcher\",\n  \"#358 - Bert Kreischer\",\n  \"#354 - Ari Shaffir, Amy Schumer\",\n  \"#349 - Greg Fitzsimmons\",\n  \"#344 - Stanley Krippner, Christopher Ryan\",\n  \"#340 - JD Kelley\",\n  \"#331 - Dr. Steven Greer\",\n  \"#324 - Sam Sheridan\",\n  \"#320 - Tim Ferriss\",\n  \"#314 - Ian Edwards\",\n  \"#303 - Matt Vengrin, Brian Redban\",\n  \"#294 - Ari Shaffir\",\n  \"#276 - David Seaman, Abby Martin, Dell Cameron, Brian Redban\",\n  \"#275 - Dave Asprey\",\n  \"#256 - David Seaman\",\n  \"#246 - Maynard J Keenan (Part 1)\",\n  \"#246 - Maynard J Keenan (Part 2)\",\n  \"#239 - Adam Kokesh\",\n  \"#232 - Giorgio Tsoukalos\",\n  \"#227 - Ari Shaffir\",\n  \"#213 - Eddie Bravo\",\n  \"#198 - Brody Stevens\",\n  \"#182 - Bryan Callen, Jimmy Burke, Brian Redban\",\n  \"#176 - Steven Rinella\",\n  \"#159 - Nick Thune\",\n  \"#155 - Dave Attell\",\n  \"#152 - Brian Redban\",\n  \"#149 - LIVE FROM THE ICEHOUSE (PART ONE)\",\n  \"#134 - Kevin Smith (Part 4)\",\n  \"#132 - Bert Kreischer\",\n  \"#128 - Joey Diaz, Brian Redban\",\n  \"#122 - Jamie Kilstein\",\n  \"#119 - Jan Irvin\",\n  \"#118 - Ari Shaffir\",\n  \"#116 - Russell Peters, Junior Simpson\",\n  \"#114 - Neal Brennan\",\n  \"#113 - Brian Posehn\",\n  \"#112 - Cliffy B, Johnny Cristo\",\n  \"#110 - Duncan Trussell\",\n  \"#108 - Joey Diaz, Brian Redban\",\n  \"#107 - Doug Benson\",\n  \"#102 - John Heffron\",\n  \"#98 - Daryl Wright, Brian Whitaker\",\n  \"#97 - Freddy Lockhart, Brian Redban\",\n  \"#92 - Jim Norton\",\n  \"#91 - Bill Burr\",\n  \"#88 - Andy Dick\",\n  \"#82 - Dave Foley\",\n  \"#81 - Pete Johansson\",\n  \"#75 - Sam Tripoli\",\n  \"#72 - Ari Shaffir\",\n  \"#66 - Nick Swardson\",\n  \"#57 - Jayson Thibault, Brian Redban\",\n  \"#55 - Duncan Trussell\",\n  \"#50 - Little Esther\",\n  \"#48 - Brian Redban\",\n  \"#42 - Duncan Trussell\",\n  \"#40 - Tyler Knight\",\n  \"#27 - Sam Tripoli\",\n  \"#21 - Brian Redban\",\n  \"#20 - Tom Segura\",\n  \"#4 - Brian Redban\",\n  \"Fight Companion - February 19, 2017\",\n];\n\nmodule.exports = missingEpisodesPerNow;\n"
  },
  {
    "path": "server/db/migration/schema.sql",
    "content": "CREATE DATABASE jre_missing;\n\nDROP TABLE IF EXISTS all_eps, test_table, date_removed, date_re_added, all_eps_log;\n\nCREATE TABLE all_eps(\n  id SERIAL NOT NULL UNIQUE PRIMARY KEY,\n  episode_number INTEGER,\n  full_name VARCHAR(255) NOT NULL, \n  on_spotify BOOLEAN NOT NULL,\n  duration INTEGER\n);\n\nCREATE TABLE test_table(\n  id SERIAL NOT NULL UNIQUE PRIMARY KEY,\n  episode_number INTEGER,\n  full_name VARCHAR(255) NOT NULL, \n  on_spotify BOOLEAN NOT NULL,\n  duration INTEGER\n);\n\nCREATE TABLE date_removed(\n  id INTEGER NOT NULL,\n  date_removed timestamptz(0) NOT NULL,\n  PRIMARY KEY (id, date_removed),\n  CONSTRAINT fk_id\n    FOREIGN KEY(id) \n\t  REFERENCES all_eps(id)\n);\n\nCREATE TABLE date_re_added(\n  id INTEGER NOT NULL,\n  re_added timestamptz(0) NOT NULL,\n  PRIMARY KEY (id, re_added),\n  CONSTRAINT fk_id\n    FOREIGN KEY(id)\n\t  REFERENCES all_eps(id)\n);\n\nCREATE TABLE all_eps_log(\n  last_checked timestamptz(0),\n  last_modified timestamptz(0)\n);\n\nCREATE TABLE duration_changes(\n  id SERIAL NOT NULL UNIQUE PRIMARY KEY,\n  episode_id INTEGER NOT NULL,\n  date timestamptz(0) NOT NULL,\n  old_duration INTEGER,\n  new_duration INTEGER,\n  CONSTRAINT fk_id\n    FOREIGN KEY(episode_id) \n\t  REFERENCES all_eps(id)\n);\n\nCREATE OR REPLACE FUNCTION change_duration() \nRETURNS TRIGGER AS $cl$ \nBEGIN \n  INSERT INTO duration_changes VALUES(DEFAULT, NEW.id, now(), OLD.duration, NEW.duration);\n  RETURN NEW;\nEND; \n$cl$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE FUNCTION last_modified() \nRETURNS TRIGGER AS $lm$ \nBEGIN \n  UPDATE all_eps_log SET last_modified=now();\n  RETURN NEW;\nEND; \n$lm$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE FUNCTION spotify_status() \nRETURNS TRIGGER AS $ss$ \nBEGIN \n  IF (new.on_spotify = true) THEN\n    INSERT INTO date_re_added VALUES(NEW.id, now());\n    RETURN NEW; \n    ELSEIF (new.on_spotify = false) THEN\n  INSERT INTO date_removed VALUES(NEW.id, now());\n    RETURN NEW; \n    END IF;\n  RETURN NULL; \nEND; \n$ss$ LANGUAGE plpgsql;\n\nCREATE TRIGGER change_duration\nAFTER UPDATE ON all_eps\nFOR EACH ROW\nWHEN (NEW.duration IS DISTINCT FROM OLD.duration)\nEXECUTE PROCEDURE change_duration();\n\nCREATE TRIGGER spotify_status\nAFTER UPDATE ON all_eps\nFOR EACH ROW\nWHEN (OLD.on_spotify IS DISTINCT FROM NEW.on_spotify)\nEXECUTE PROCEDURE spotify_status();\n\nCREATE TRIGGER last_modified\nAFTER UPDATE OR INSERT OR DELETE ON all_eps\nFOR EACH ROW\nEXECUTE PROCEDURE last_modified();"
  },
  {
    "path": "server/db/migration/seeds.sql",
    "content": "INSERT INTO all_eps_log VALUES(now(), NULL);"
  },
  {
    "path": "server/db/migration/setup.js",
    "content": "const missingEpisodesPerNow = require(\"./missingEpisodesPerNow\");\nconst getSpotifyEpisodes = require(\"../../lib/getSpotifyEpisodes\");\nconst getEpisodeNumber = require(\"../../lib/getEpisodeNumber\");\nconst pool = require(\"../connect\");\n\nrequire(\"dotenv\").config();\n\ngetSpotifyEpisodes().then(async (episodes) => {\n  const allEpisodes = episodes.concat(missingEpisodesPerNow);\n  allEpisodes.sort((a, b) => getEpisodeNumber(a.name) - getEpisodeNumber(b.name));\n\n  await (async () => {\n    for (const ep of allEpisodes) {\n      const client = await pool.connect();\n      const epNumber = getEpisodeNumber(ep.name);\n\n      try {\n        const onSpotify = !missingEpisodesPerNow.includes(ep.name);\n        await client.query(`INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)`, [\n          epNumber,\n          ep.name,\n          onSpotify,\n          ep.duration,\n        ]);\n      } finally {\n        client.release();\n      }\n    }\n  })().catch((err) => console.error(err.message));\n\n  console.info(\"inserts done\");\n});\n"
  },
  {
    "path": "server/lib/getEpisodeNumber.js",
    "content": "// Matches episodes starting with a number, either with or without preceding hashtag (#)\nfunction getEpisodeNumber(name) {\n  return parseInt(name?.match(/^(\\d+)|(?<=^#)(\\d+)/)) || null;\n}\n\nmodule.exports = getEpisodeNumber;\n"
  },
  {
    "path": "server/lib/getEpisodeNumber.test.js",
    "content": "const getEpisodeNumber = require(\"./getEpisodeNumber\");\n\ndescribe(\"Return number from regular episode title where the episode name starts with the number\", () => {\n  test(\"Return number when hashtag precedes number (i.e. #42)\", () => {\n    expect(getEpisodeNumber(\"#1789 - Tom Papa\")).toBe(1789);\n  });\n\n  test(\"Return number when no hashtag precedes number\", () => {\n    expect(getEpisodeNumber(\"32 - Duncan Trussell\")).toBe(32);\n  });\n});\n\ndescribe(\"Do not return episode number when the episode name do not start with a number\", () => {\n  test(\"return null when episode number is not at the beginning of the title\", () => {\n    expect(getEpisodeNumber(\"JRE #1789 - Tom Papa\")).toBeNull();\n    expect(getEpisodeNumber(\"JRE 1789 - Tom Papa\")).toBeNull();\n    expect(getEpisodeNumber(\"Fight Companion #32 - Joey Diaz\")).toBeNull();\n  });\n\n  test(\"return null when there is no episode number in the title\", () => {\n    expect(getEpisodeNumber(\"Fight Companion - February 19, 2017 (Part 2\")).toBeNull();\n  });\n});\n\ndescribe(\"Is type of number\", () => {\n  test(\"return a number and not a string when episode name start with an episode number\", () => {\n    expect(typeof getEpisodeNumber(\"#1789 - Tom Papa\")).toBe(\"number\");\n  });\n});\n"
  },
  {
    "path": "server/lib/getSpotifyEpisodes.js",
    "content": "/* eslint-disable no-undef */\nconst SpotifyWebApi = require(\"spotify-web-api-node\");\nrequire(\"dotenv\").config();\n\nconst JRE_SHOW_ID = \"4rOoJ6Egrf8K2IrywzwOMk\";\n\nasync function getSpotifyEpisodes() {\n  try {\n    const spotifyApi = new SpotifyWebApi({\n      clientId: process.env.SPOTIFY_CLIENT_ID,\n      clientSecret: process.env.SPOTIFY_CLIENT_SECRET,\n    });\n\n    const tokenData = await spotifyApi.clientCredentialsGrant();\n    console.info(`The access token expires in ${tokenData.body[\"expires_in\"]}`);\n    spotifyApi.setAccessToken(tokenData.body[\"access_token\"]);\n\n    const spotifyEpisodes = [];\n    const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {\n      market: \"US\",\n      limit: 50,\n      offset: spotifyEpisodes.length,\n    });\n\n    console.info(`Started fetching episodes from Spotify at ${new Date().toString()}`);\n\n    spotifyEpisodes.push(\n      ...episodes.body.items.map((ep) => {\n        return { name: ep.name, duration: ep.duration_ms };\n      })\n    );\n\n    const totalEpisodes = episodes.body.total;\n\n    while (spotifyEpisodes.length < totalEpisodes) {\n      const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {\n        market: \"US\",\n        limit: 50,\n        offset: spotifyEpisodes.length,\n      });\n      spotifyEpisodes.push(\n        ...episodes.body.items.map((ep) => {\n          return { name: ep.name, duration: ep.duration_ms };\n        })\n      );\n    }\n    console.log(\n      `${spotifyEpisodes.length} out of ${totalEpisodes} JRE episodes on Spotify successfully fetched`\n    );\n\n    return spotifyEpisodes;\n  } catch (err) {\n    console.error(err.message);\n  }\n}\n\nmodule.exports = getSpotifyEpisodes;\n"
  },
  {
    "path": "server/package.json",
    "content": "{\n  \"name\": \"server\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"app.js\",\n  \"scripts\": {\n    \"start\": \"node app.js\",\n    \"dev\": \"cross-env NODE_ENV=development npm run dev-server\",\n    \"dev:all\": \"cross-env NODE_ENV=development run-p dev-server worker\",\n    \"dev-server\": \"nodemon app.js\",\n    \"worker\": \"node worker/worker.js\",\n    \"refresh-db\": \"node worker/tasks/refreshDb.js\",\n    \"setup\": \"node db/migration/setup.js\",\n    \"test\": \"jest\",\n    \"test:coverage\": \"jest --coverage\"\n  },\n  \"author\": \"\",\n  \"dependencies\": {\n    \"date-fns\": \"^2.30.0\",\n    \"date-fns-tz\": \"^2.0.0\",\n    \"dotenv\": \"^16.3.1\",\n    \"express\": \"^4.18.2\",\n    \"express-rate-limit\": \"^7.1.3\",\n    \"express-slow-down\": \"^2.0.0\",\n    \"helmet\": \"^7.0.0\",\n    \"node-schedule\": \"^2.1.1\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"pg\": \"^8.11.3\",\n    \"spotify-web-api-node\": \"^5.0.2\"\n  },\n  \"devDependencies\": {\n    \"cors\": \"^2.8.5\",\n    \"cross-env\": \"^7.0.3\",\n    \"eslint\": \"^8.52.0\",\n    \"eslint-config-prettier\": \"^9.0.0\",\n    \"jest\": \"^29.7.0\",\n    \"nodemon\": \"^3.0.1\"\n  }\n}\n"
  },
  {
    "path": "server/utils/utils.js",
    "content": "const { zonedTimeToUtc, utcToZonedTime, format: formatTz } = require(\"date-fns-tz\");\n\nconst getClientLocalTime = (date, pattern) => {\n  const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n  const utcDate = zonedTimeToUtc(date, userTimezone);\n  const zonedDate = utcToZonedTime(utcDate, userTimezone);\n  const lastCheckedDate = formatTz(zonedDate, pattern, {\n    timeZone: userTimezone,\n  });\n  return lastCheckedDate;\n};\n\nconst getDateString = (time) => {\n  return getClientLocalTime(time, \"PPP\");\n};\n\nconst getDateTimeHTMLAttribute = (time) => {\n  return getClientLocalTime(time, \"yyyy-MM-dd\");\n};\n\nconst formatMsToTimeString = (time) => {\n  const hours = Math.floor(time / 1000 / 60 / 60);\n  const minutesRest = (time / 1000 / 60) % 60;\n  const seconds = (time / 1000) % 60;\n  return `${Math.floor(hours)} hr ${Math.floor(minutesRest)} min ${Math.floor(seconds)} sec`;\n};\n\nmodule.exports = {\n  getClientLocalTime,\n  formatMsToTimeString,\n  getDateString,\n  getDateTimeHTMLAttribute,\n};\n"
  },
  {
    "path": "server/worker/tasks/refreshDb.js",
    "content": "const pg = require(\"pg\");\nconst pool = new pg.Pool();\n\nconst DB = require(\"../../db/db\");\nconst getEpisodeNumber = require(\"../../lib/getEpisodeNumber\");\nconst getSpotifyEpisodes = require(\"../../lib/getSpotifyEpisodes\");\n\nasync function refreshDb() {\n  await (async () => {\n    const client = await pool.connect();\n    const db = DB(client);\n    try {\n      console.info(\"worker running\");\n\n      const spotifyEpisodes = await getSpotifyEpisodes();\n      if (!spotifyEpisodes) {\n        throw new Error(\"No spotify episodes got fetched!\");\n      }\n      const spotifyEpisodeNames = spotifyEpisodes.map((ep) => ep.name);\n      let allEpisodes = await db.getAllEpisodes();\n      let someEpisodeNameGotUpdated = false;\n\n      for (const dbEpisode of allEpisodes) {\n        const correspondingSpotifyEpisode = spotifyEpisodes.find(\n          (ep) => ep.name === dbEpisode.full_name\n        );\n\n        if (correspondingSpotifyEpisode && !dbEpisode.duration) {\n          console.info(\n            `Inserting missing duration for episode ${dbEpisode.full_name} (duration: ${correspondingSpotifyEpisode.duration}) `\n          );\n          await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);\n        } else if (correspondingSpotifyEpisode) {\n          if (correspondingSpotifyEpisode.duration !== dbEpisode.duration) {\n            await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);\n            console.info(\n              ` \\n\\n Spotify has changed the duration of episode: ${dbEpisode.full_name} \\n\n                      from: ${dbEpisode.duration} \\n \n                      to: ${correspondingSpotifyEpisode.duration} \\n\\n`\n            );\n          }\n        }\n      }\n\n      for (const spotifyEpisode of spotifyEpisodes) {\n        for (const dbEpisode of allEpisodes) {\n          if (\n            dbEpisode.episode_number &&\n            didEpisodeChangeName(spotifyEpisode.name, dbEpisode)\n          ) {\n            await db.updateEpisodeName(spotifyEpisode.name, dbEpisode.id);\n            someEpisodeNameGotUpdated = true;\n            console.info(\n              ` \\n\\n spotify updated the name of an episode! \\n\n                                  from: ${dbEpisode.full_name} \\n \n                                  to: ${spotifyEpisode.name} \\n\\n`\n            );\n            break;\n          }\n        }\n        const isNewRelease = !allEpisodes.some((ep) => ep.full_name === spotifyEpisode.name);\n        if (isNewRelease) {\n          console.info(`New episode released: ${spotifyEpisode.name}`);\n          await db.insertNewEpisode(spotifyEpisode);\n        }\n      }\n\n      if (someEpisodeNameGotUpdated) allEpisodes = await db.getAllEpisodes();\n\n      for (const dbEpisode of allEpisodes) {\n        if (!spotifyEpisodeNames.includes(dbEpisode.full_name)) {\n          if (dbEpisode.on_spotify) {\n            await db.setSpotifyStatus(dbEpisode, false);\n            console.info(`\\n\\nNew episode removed!: ${dbEpisode.full_name} \\n\\n`);\n          }\n        } else if (!dbEpisode.on_spotify) {\n          await db.setSpotifyStatus(dbEpisode, true);\n          console.info(`\\n\\nNew episode re-added: ${dbEpisode.full_name} \\n\\n`);\n        }\n      }\n\n      await db.setLastCheckedNow();\n      console.info(\"Worker ran successfully\");\n    } finally {\n      client.release();\n    }\n  })().catch((err) => console.warn(`Worker failed to run: ${err.message}`));\n}\n\nfunction didEpisodeChangeName(spotifyEpisodeName, dbEpisode) {\n  const epNumber = getEpisodeNumber(spotifyEpisodeName);\n  const { full_name, episode_number } = dbEpisode;\n  return (\n    full_name !== spotifyEpisodeName &&\n    epNumber == episode_number &&\n    !full_name.toLowerCase().includes(\"(part\") &&\n    !spotifyEpisodeName.toLowerCase().includes(\"(part\")\n  );\n}\n\nrefreshDb();\n\nmodule.exports = refreshDb;\n"
  },
  {
    "path": "server/worker/worker.js",
    "content": "const schedule = require(\"node-schedule\");\nconst refreshDb = require(\"./tasks/refreshDb\");\nrequire(\"dotenv\").config();\n// eslint-disable-next-line no-undef\nconst { CRON_INTERVAL } = process.env;\n\nschedule.scheduleJob(`*/${CRON_INTERVAL} * * * *`, refreshDb);\n"
  }
]