Full Code of henb13/jre-missing for AI

main 8ace00ee0e62 cached
78 files
92.8 KB
27.5k tokens
18 symbols
1 requests
Download .txt
Repository: henb13/jre-missing
Branch: main
Commit: 8ace00ee0e62
Files: 78
Total size: 92.8 KB

Directory structure:
gitextract_5r8ltyem/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── codeql.yml
│       └── main.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── client/
│   ├── .eslintrc.json
│   ├── index.html
│   ├── package.json
│   ├── public/
│   │   ├── ads.txt
│   │   └── robots.txt
│   ├── src/
│   │   ├── components/
│   │   │   ├── AmountInfo.jsx
│   │   │   ├── AmountInfo.module.css
│   │   │   ├── App.css
│   │   │   ├── App.jsx
│   │   │   ├── ChangeDetails.jsx
│   │   │   ├── ChangeDetails.module.css
│   │   │   ├── Coffee.jsx
│   │   │   ├── Coffee.module.css
│   │   │   ├── Contact.jsx
│   │   │   ├── Contact.module.css
│   │   │   ├── Disclosure.jsx
│   │   │   ├── Disclosure.module.css
│   │   │   ├── Episode.jsx
│   │   │   ├── Episode.module.css
│   │   │   ├── EpisodeList.jsx
│   │   │   ├── EpisodeList.module.css
│   │   │   ├── Error.jsx
│   │   │   ├── Error.module.css
│   │   │   ├── Github.jsx
│   │   │   ├── Github.module.css
│   │   │   ├── Header.jsx
│   │   │   ├── Header.module.css
│   │   │   ├── ListTabs.jsx
│   │   │   ├── ListTabs.module.css
│   │   │   ├── ScrollButton.jsx
│   │   │   ├── ScrollButton.module.css
│   │   │   ├── Searchbox.jsx
│   │   │   ├── Searchbox.module.css
│   │   │   ├── Sort.jsx
│   │   │   ├── Sort.module.css
│   │   │   ├── Sponsor.jsx
│   │   │   ├── Sponsor.module.css
│   │   │   ├── Tag.jsx
│   │   │   └── Tag.module.css
│   │   ├── hooks/
│   │   │   ├── useFetch.js
│   │   │   ├── useMinLoadingTime.js
│   │   │   └── useScroll.js
│   │   ├── index.css
│   │   ├── index.jsx
│   │   ├── skeletons/
│   │   │   ├── SkeletonList.jsx
│   │   │   ├── SkeletonStyles.module.css
│   │   │   └── SkeletonText.jsx
│   │   ├── utils.js
│   │   └── utils.test.js
│   ├── tsconfig.json
│   ├── vite-env.d.ts
│   └── vite.config.ts
└── server/
    ├── .eslintrc.json
    ├── api/
    │   ├── __mocks__/
    │   │   └── mockResponse.js
    │   ├── dev-api.js
    │   └── index.js
    ├── app.js
    ├── db/
    │   ├── connect.js
    │   ├── db.js
    │   ├── mapQueries.js
    │   └── migration/
    │       ├── missingEpisodesPerNow.js
    │       ├── schema.sql
    │       ├── seeds.sql
    │       └── setup.js
    ├── lib/
    │   ├── getEpisodeNumber.js
    │   ├── getEpisodeNumber.test.js
    │   └── getSpotifyEpisodes.js
    ├── package.json
    ├── utils/
    │   └── utils.js
    └── worker/
        ├── tasks/
        │   └── refreshDb.js
        └── worker.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
github: [cotterjd]


================================================
FILE: .github/workflows/codeql.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"

on:
  push:
    branches: [ "main", **/** ]
  pull_request:
    # The branches below must be a subset of the branches above
    branches: [ "main" ]
  schedule:
    - cron: '37 1 * * 0'

jobs:
  analyze:
    name: Analyze
    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
    timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: [ 'javascript' ]
        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ]
        # Use only 'java' to analyze code written in Java, Kotlin or both
        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    # Initializes the CodeQL tools for scanning.
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v2
      with:
        languages: ${{ matrix.language }}
        # If you wish to specify custom queries, you can do so here or in a config file.
        # By default, queries listed here will override any specified in a config file.
        # Prefix the list here with "+" to use these queries and those in the config file.

        # 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
        # queries: security-extended,security-and-quality


    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
    # If this step fails, then you should remove it and run the build manually (see below)
    - name: Autobuild
      uses: github/codeql-action/autobuild@v2

    # ℹ️ Command-line programs to run using the OS shell.
    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun

    #   If the Autobuild fails above, remove it and uncomment the following three lines.
    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.

    # - run: |
    #     echo "Run, Build Application using script"
    #     ./location_of_script_within_repo/buildscript.sh

    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v2
      with:
        category: "/language:${{matrix.language}}"


================================================
FILE: .github/workflows/main.yml
================================================
name: Run Tests

on:
  push:
    branches:
      - "*"
  pull_request:
    branches:
      - "*"

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x]
        directory: ["./server", "./client"]

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
          cache-dependency-path: ${{ matrix.directory }}/package-lock.json

      - name: Install dependencies
        run: |
          cd ${{ matrix.directory }}
          npm ci

      - name: Build project
        run: |
          cd ${{ matrix.directory }}
          npm run build --if-present

      - name: Run tests
        run: |
          cd ${{ matrix.directory }}
          npm run test


================================================
FILE: .gitignore
================================================
.vscode
.DS_Store
**/.DS_Store

# client

## dependencies
client/node_modules
client/.pnp
client/.pnp.js

## testing
client/coverage


## production
client/build
client/dist

## misc
client/.DS_Store
client/.env
client/.env.local
client/.env.development.local
client/.env.test.local
client/.env.production.local

client/npm-debug.log*
client/yarn-debug.log*
client/yarn-error.log*


# server

server/.env
server/node_modules
server/coverage

================================================
FILE: .prettierrc
================================================
{
  "printWidth": 95,
  "tabWidth": 2,
  "bracketSameLine": true
}


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 HenB13

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: README.md
================================================
<div align="center" text-align="center">

# JRE MISSING <img height="23px" width="23px" padding="5px" src="./client/public/favicon.png" alt="JRE MISSING LOGO" />

<p>
  <img alt="Repository size" src="https://img.shields.io/github/repo-size/HenB13/jre-missing?color=#ee7d2c">
  <img alt="Repository license" src="https://img.shields.io/github/license/HenB13/jre-missing?color=#ee7d2c">
  <img alt="Repository license" src="https://img.shields.io/static/v1?label=sauna+temp&message=200F&color=#ee7d2c">
  
</p>

Automatically 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.

## As Mentioned In 🗞️

<a href="https://www.washingtonpost.com/arts-entertainment/2022/02/06/spotify-joe-rogan-podcast-removed/">The Washington Post</a>
 &#8226;  <a href="https://www.nytimes.com/2022/02/05/arts/music/joe-rogan-spotify-apology-slur.html">The New York Times</a>
 &#8226;  <a href="https://www.rollingstone.com/culture/culture-news/spotify-removes-joe-rogan-experience-podcast-episodes-1295727/">Rolling Stone</a>
 &#8226;  <a href="https://www.theverge.com/22918697/joe-rogan-experience-podcast-episodes-disappear-controversy">The Verge</a>
 &#8226;  <a href="https://www.bloomberg.com/news/articles/2022-02-05/joe-rogan-apologizes-for-using-racial-slur-on-his-podcast">Bloomberg</a>
 &#8226;  <a href="https://variety.com/2022/digital/news/spotify-removes-joe-rogan-episodes-n-word-1235172972/">Variety</a>
 &#8226;  <a href="https://edition.cnn.com/2022/02/05/media/joe-rogan-racial-slur-apology-india-arie/index.html">CNN</a>
 &#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>
 &#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>
 &#8226;  <a href="https://nypost.com/2022/02/05/spotify-has-removed-over-100-episodes-of-joe-rogans-podcast/">New York Post</a>
 &#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>
 &#8226;  <a href="https://www.wsj.com/articles/joe-rogan-racial-slur-spotify-11644275660">The Wall Street Journal</a>
 &#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>
 &#8226;  <a href="https://www.newsweek.com/spotify-draws-line-between-slurs-covid-removing-some-joe-rogan-episodes-1676887">Newsweek</a>
 &#8226;  <a href="https://www.huffpost.com/entry/joe-rogan-experience-episodes-removed-spotify_n_61fef043e4b0f8a1b8453a83">HuffPost</a>
 &#8226;  <a href="https://www.independent.co.uk/arts-entertainment/music/news/joe-rogan-podcast-episodes-removed-b2009035.html">The Independent</a>
 &#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>

## Sponsors 💜

<a href="https://github.com/cotterjd">Jordan Cotter</a> &#8226;

</div>


================================================
FILE: client/.eslintrc.json
================================================
{
  "env": {
    "browser": true,
    "es2021": true,
    "jest": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "prettier"
  ],
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": ["react", "react-hooks"],
  "rules": {
    "react/prop-types": 0,
    "react/jsx-uses-react": "off",
    "react/react-in-jsx-scope": "off",
    "react-hooks/exhaustive-deps": "warn",
    "prefer-const": [
      "warn",
      {
        "destructuring": "all",
        "ignoreReadBeforeAssign": false
      }
    ],
    "prefer-template": "warn"
  },
  "ignorePatterns": ["node_modules/", "coverage/", "build/"]
}


================================================
FILE: client/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="favicon.png" />
    <script
      async
      src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2393608933506016"
      crossorigin="anonymous"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      name="description"
      content="Detects missing Spotify episodes of The Joe Rogan Experience podcast" />
    <link rel="apple-touch-icon" href="logo192.png" />
    <title>JRE Missing</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>

    <script type="module" src="/src/index.jsx"></script>
  </body>
</html>


================================================
FILE: client/package.json
================================================
{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@types/node": "^20.8.9",
    "@types/react": "^18.2.33",
    "@vitejs/plugin-react-swc": "^3.4.0",
    "classnames": "^2.3.2",
    "date-fns": "^2.30.0",
    "date-fns-tz": "^2.0.0",
    "lodash": "^4.17.21",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-text-transition": "^3.1.0",
    "react-tooltip": "^5.21.6",
    "typescript": "^5.2.2",
    "vite": "^4.5.0",
    "vite-plugin-svgr": "^4.1.0",
    "vite-tsconfig-paths": "^4.2.1",
    "web-vitals": "^3.5.0"
  },
  "scripts": {
    "start": "vite",
    "build": "tsc && vite build",
    "serve": "vite preview",
    "test": "vitest",
    "test:coverage": "vitest run --coverage --watch=false"
  },
  "devDependencies": {
    "@vitest/coverage-v8": "^0.34.6",
    "eslint": "^8.52.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-react": "^7.33.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "jsdom": "^22.1.0",
    "vitest": "^0.34.6"
  },
  "eslintConfig": {
    "extends": [
      "react-app"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}


================================================
FILE: client/public/ads.txt
================================================
google.com, pub-2393608933506016, DIRECT, f08c47fec0942fa0

================================================
FILE: client/public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:


================================================
FILE: client/src/components/AmountInfo.jsx
================================================
import classnames from "classnames";
import styles from "./AmountInfo.module.css";
import { getClientLocalTime, formatMinutesToTimeAmountString } from "../utils";
import Checkmark from "../icons/AmountInfoIcon.svg";
import SkeletonText from "../skeletons/SkeletonText.jsx";

const AmountInfo = ({ data, showSkeleton, setListShown }) => {
  if (showSkeleton) return <SkeletonText />;
  if (!data || !data.missingEpisodes || !data.shortenedEpisodes) return null;

  const { missingEpisodes, shortenedEpisodes } = data;

  const lastChecked = data.lastCheckedInMs;
  const lastCheckedMinutes = lastChecked
    ? Math.floor((new Date() - new Date(lastChecked)) / 60000)
    : 0;

  const lastCheckedString = formatMinutesToTimeAmountString(lastCheckedMinutes);
  const lastCheckedDate = getClientLocalTime(lastChecked, "PP HH:mm");

  const dateTimeHTMLAttribute = getClientLocalTime(lastChecked, "yyyy-MM-dd HH:mm:ss.sss");

  return (
    <div className={styles.AmountInfo}>
      <button onClick={() => setListShown("removed")} className={styles.AmountInfoItem}>
        <span
          className={classnames(styles.count, {
            [styles.NoAmount]: missingEpisodes.length === 0,
          })}>
          {missingEpisodes.length}
        </span>{" "}
        episodes are missing from Spotify.
      </button>
      <button onClick={() => setListShown("shortened")} className={styles.AmountInfoItem}>
        <span
          className={classnames(styles.count, {
            [styles.NoAmount]: shortenedEpisodes.length === 0,
          })}>
          {shortenedEpisodes.length}
        </span>{" "}
        episode
        {shortenedEpisodes.length === 1 ? "" : "s"}{" "}
        {shortenedEpisodes.length == 1 ? "has" : "have"} been shortened.
      </button>
      <div className={styles.LastChecked}>
        <p>
          Last checked: {lastCheckedString} ago
          <time dateTime={dateTimeHTMLAttribute}> ({lastCheckedDate})</time>
        </p>
        <Checkmark className={styles.Checkmark} />
      </div>
    </div>
  );
};

export default AmountInfo;


================================================
FILE: client/src/components/AmountInfo.module.css
================================================
.AmountInfo {
  font-size: 2rem;
}

.AmountInfoItem {
  display: block;
  white-space: nowrap;
}

.AmountInfoItem .count {
  color: var(--color-red);
  font-weight: 700;
  position: relative;
}

.AmountInfoItem .count:after {
  content: "";
  position: absolute;
  width: 100%;
  height: 0;
  right: 0;
  bottom: 1px;
  border-bottom: 2px solid var(--color-red);
}
.AmountInfoItem .count.NoAmount:after {
  border-color: var(--color-green);
}

.LastChecked {
  font-size: 1.4rem;
  display: flex;
  text-align: center;
  margin-top: 1px;
  color: var(--color-grey);
  margin-bottom: 3rem;
}

.count.NoAmount {
  color: var(--color-green);
}

.Checkmark {
  margin: -1px 0 0 6px;
  color: var(--color-green);
}

@media (max-width: 1350px) {
  .AmountInfo {
    font-size: 1.8rem;
    margin: 4.5rem 0 1.75rem;
    text-align: center;
  }

  .AmountInfoItem {
    margin: 0 auto;
  }

  .AmountInfo .count:after {
    bottom: 0;
    width: 95%;
  }

  .LastChecked {
    justify-content: center;
    margin-left: 1.25rem;
    margin-top: 0.7rem;
  }

  .LastChecked time {
    display: none;
  }
}


================================================
FILE: client/src/components/App.css
================================================
.App {
  display: flex;
  position: relative;
}

.left {
  position: fixed;
  top: 26rem;
  bottom: 19rem;
  left: 35rem;
}

.right {
  display: flex;
  align-items: flex-start;
  margin: 9rem 0rem 9rem 49vw;
}

@media (max-width: 1700px) {
  .left {
    left: 20rem;
    top: 17rem;
  }
}

@media (max-width: 1450px) {
  .left {
    left: 14rem;
  }
}

@media (max-width: 1350px) {
  .App {
    flex-direction: column;
    align-items: center;
    margin-bottom: 6rem;
  }

  .left,
  .right {
    position: unset;
    display: flex;
    flex-direction: column;
    align-items: center;
    margin: unset;
  }

  .right {
    margin-top: 2rem;
  }
}

@media (max-width: 750px) {
  .App {
    padding: 0 5rem;
  }
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}


================================================
FILE: client/src/components/App.jsx
================================================
import "./App.css";
import { useState, useEffect } from "react";
import useFetch from "../hooks/useFetch";
import useMinLoadingTime from "../hooks/useMinLoadingTime";
import Error from "./Error";
import Github from "./Github";
import Header from "./Header";
import AmountInfo from "./AmountInfo";
import EpisodeList from "./EpisodeList";
import Sort from "./Sort";
import Searchbox from "./Searchbox";
import ScrollButton from "./ScrollButton";
import Contact from "./Contact";
import Sponsor from "./Sponsor";
import Coffee from "./Coffee";
import useScroll from "../hooks/useScroll";

function App() {
  const { data, error, isPending } = useFetch(
    `${import.meta.env.VITE_API_BASE_URL}/api/episodes`
  );
  const minLoadingTimeElapsed = useMinLoadingTime(400);
  const [shouldShakeEpisodes, setShouldShakeEpisodes] = useState(false);
  const [missingEpisodesShown, setMissingEpisodesShown] = useState([]);
  const [shortenedEpisodesShown, setShortenedEpisodesShown] = useState([]);
  const [listShown, setListShown] = useState("removed");
  const [searchText, setSearchText] = useState("");

  const listMap = {
    removed: {
      episodes: missingEpisodesShown,
      allEpisodes: data?.missingEpisodes || [],
      setEpisodes: setMissingEpisodesShown,
    },
    shortened: {
      episodes: shortenedEpisodesShown,
      allEpisodes: data?.shortenedEpisodes || [],
      setEpisodes: setShortenedEpisodesShown,
    },
  };

  const currentList = listMap[listShown];

  useEffect(() => {
    setMissingEpisodesShown(data?.missingEpisodes || []);
    setShortenedEpisodesShown(data?.shortenedEpisodes || []);
  }, [data]);

  const shakeEpisodes = () => {
    setShouldShakeEpisodes(true);
    setTimeout(() => {
      setShouldShakeEpisodes(false);
    }, 1000);
  };

  const { scrollTarget, scrollable } = useScroll({
    refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],
  });

  const showSkeleton = isPending || !minLoadingTimeElapsed;

  const resetCurrentEpisodes = () => {
    currentList.setEpisodes(currentList.allEpisodes);
    setSearchText("");
  };

  return (
    <div className="App">
      <section className="left">
        <Github />
        <Sponsor />
        <Coffee />
        <Header />
        <Contact />
        {error ? (
          <Error error={error} />
        ) : (
          <>
            <AmountInfo data={data} showSkeleton={showSkeleton} setListShown={setListShown} />
            <Searchbox
              {...currentList}
              shakeEpisodes={shakeEpisodes}
              searchText={searchText}
              setSearchText={setSearchText}
            />
          </>
        )}
      </section>

      {!error && (
        <section className="right">
          <Sort
            listShown={listShown}
            setEpisodes={currentList.setEpisodes}
            episodes={currentList.episodes}
          />
          <EpisodeList
            missingEpisodesShown={missingEpisodesShown}
            shortenedEpisodesShown={shortenedEpisodesShown}
            shouldShake={shouldShakeEpisodes}
            showSkeleton={showSkeleton}
            searchText={searchText}
            listShown={listShown}
            setListShown={setListShown}
            resetCurrentEpisodes={resetCurrentEpisodes}
          />
          <ScrollButton
            dataPending={isPending}
            minLoadingTimeElapsed={minLoadingTimeElapsed}
            scrollTarget={scrollTarget}
            scrollable={scrollable}
          />
        </section>
      )}
    </div>
  );
}

export default App;


================================================
FILE: client/src/components/ChangeDetails.jsx
================================================
import { useState } from "react";
import classnames from "classnames";
import Disclosure from "./Disclosure";
import styles from "./ChangeDetails.module.css";
import Chavron from "../icons/chavron.svg";

const ChangeDetails = ({ episode }) => {
  const [open, setOpen] = useState(false);
  const disclosureId = `${episode.full_name}-toggle`;

  const [latestChange, ...restOfChanges] = episode.changes;

  const disclosureProps = {
    isOpen: open,
    onClick: () => {
      setOpen((open) => !open);
    },
    ariaControls: `${episode.full_name}-change-history-wrapper`,
    id: disclosureId,
  };

  return (
    <div className={styles.ChangeDetails}>
      <div className={styles.ChangeDisplay}>
        <p>
          <span className={styles.displayTime}>{latestChange.old_duration_string}</span>
        </p>
        <span>&gt;</span>
        <p>
          <span className={styles.displayTime}>{latestChange.new_duration_string}</span>
        </p>
      </div>
      {restOfChanges.length > 0 && (
        <div className={styles.restOfChanges}>
          <div className={styles.headingWrapper}>
            <p className={styles.heading}>
              Has been changed {restOfChanges.length} {!episode.isOriginalLength && "more"}{" "}
              time
              {restOfChanges.length === 1 ? "" : "s"} previously.
            </p>
            <Disclosure className={styles.historyToggle} {...disclosureProps}>
              <span>View change history</span>
              <Chavron
                className={classnames(styles.Chavron, {
                  [styles.open]: open,
                })}
              />
            </Disclosure>
          </div>
          <div
            aria-expanded={open}
            aria-labelledby={disclosureId}
            id={`${episode.full_name}-change-history-wrapper`}>
            {open && (
              <div className={styles.restOfChangesItems}>
                {[latestChange, ...restOfChanges].map((change) => {
                  return (
                    <ChangeDisplay
                      change={change}
                      key={`${episode.id}-${change.date.ms}-${change.new_duration}`}
                    />
                  );
                })}
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
};

const ChangeDisplay = ({ change }) => {
  const {
    date: { formatted, htmlAttribute },
    old_duration_string,
    new_duration_string,
  } = change;
  return (
    <div className={styles.ChangeDisplayWrapper}>
      <time dateTime={htmlAttribute} className={styles.ChangeDisplayDate}>
        {formatted}
      </time>
      <p className={styles.ChangeDisplay}>
        <span className={styles.displayTime}>{old_duration_string}</span>
        <span>&gt;</span>
        <span className={styles.displayTime}>{new_duration_string}</span>
      </p>
    </div>
  );
};

export default ChangeDetails;


================================================
FILE: client/src/components/ChangeDetails.module.css
================================================
.ChangeDetails {
  display: flex;
  flex-direction: column;
  font-size: var(--font-size-small);
  color: var(--color-grey-secondary);
  margin-top: 0.75rem;
}

.headingWrapper {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  background: var(--color-black-secondary);
  padding: 2px 8px 4px;
  border-radius: 5px;
}

.heading {
  color: var(--color-white);
  font-size: var(--font-size-small);
  font-weight: 400;
  white-space: nowrap;
}

.ChangeDisplay {
  display: flex;
}

.ChangeDisplayWrapper {
  display: flex;
  flex-direction: column;
}

.ChangeDisplay {
  display: flex;
  flex-direction: row;
  gap: 1rem;
}

.historyToggle {
  color: var(--color-grey);
  white-space: nowrap;
}

.historyToggle svg {
  width: 20px;
  height: 20px;
}

.ChangeDisplayDate {
  color: var(--color-grey);
  margin-bottom: 2px;
}

.restOfChanges {
  width: fit-content;
  margin-top: 1rem;
}

.restOfChangesItems {
  display: flex;
  flex-direction: column;
  background-color: var(--color-black-secondary);
  gap: 1.75rem;
  padding: 1.5rem;
  margin-top: -4px;
}

.Chavron {
  transform: rotate(180deg) scale(0.65);
  margin-top: 3px;
  transition: transform 0.25s ease-out;
}

.Chavron.open {
  transform: rotate(0deg) scale(0.65);
  margin-top: 3px;
}

@media (max-width: 1350px) {
  .ChangeDetails {
    align-items: center;
  }
}

@media (max-width: 400px) {
  .restOfChanges {
    margin-top: 1.75rem;
  }

  .headingWrapper {
    flex-direction: column;
    gap: 0;
    padding: 6px 32px;
  }
}


================================================
FILE: client/src/components/Coffee.jsx
================================================
import styles from "./Coffee.module.css";

const Coffee = () => {
  return (
    <>
      <a
        className={styles.Coffee}
        href="https://www.buymeacoffee.com/henbc13"
        target="_blank"
        rel="noopener noreferrer"
        aria-label="Buy Me a Coffee">
        <img
          src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png"
          alt="Buy Me a Coffee logo"
          className={styles.CoffeeLogo}
        />
      </a>
    </>
  );
};

export default Coffee;


================================================
FILE: client/src/components/Coffee.module.css
================================================
.Coffee {
  position: fixed;
  top: 3.65rem;
  left: 36rem;
  font-weight: 500;
  font-size: 16px;
  cursor: pointer;
}

.CoffeeLogo {
  max-height: 30px;
}

@media (max-width: 1350px) {
  .Coffee {
    left: 24rem;
    position: absolute;
  }
}

@media (max-width: 750px) {
  .Coffee {
    top: 1.65rem;
    left: 21rem;
  }
}

@media (max-width: 380px) {
  .Coffee {
    top: 1.5rem;
    left: 15rem;
  }
}


================================================
FILE: client/src/components/Contact.jsx
================================================
import styles from "./Contact.module.css";

import EmailIcon from "../icons/email.svg";

const Contact = () => {
  return (
    <a
      href="mailto:henbc13@gmail.com"
      className={styles.contact}
      aria-label="send me an email">
      <span>contact me</span>
      <EmailIcon className={styles.contactIcon} />
    </a>
  );
};

export default Contact;


================================================
FILE: client/src/components/Contact.module.css
================================================
.contact {
  position: fixed;
  display: flex;
  align-items: center;
  font-size: var(--font-size-medium);
  top: 4rem;
  right: 5rem;
}

.contactIcon {
  height: 2rem;
  width: 2rem;
  margin-left: 1rem;
  transition: transform 0.2s;
}

.contact:hover .contactIcon {
  transform: scale(1.2);
  cursor: pointer;
}

.contact:active .contactIcon {
  transform: scale(1);
}

@media (max-width: 1350px) {
  .contact {
    position: absolute;
  }
}

@media (max-width: 750px) {
  .contact {
    top: 2rem;
    right: 2rem;
  }
}
@media (max-width: 450px) {
  .contact span {
    display: none;
  }
}


================================================
FILE: client/src/components/Disclosure.jsx
================================================
import styles from "./Disclosure.module.css";
import classnames from "classnames";

const Disclosure = ({ isOpen, onClick, className, ariaControls, id, children }) => {
  return (
    <button
      id={id}
      aria-controls={ariaControls}
      className={classnames(className, styles.Disclosure, {
        [styles.open]: isOpen,
      })}
      onClick={onClick}>
      {children}
    </button>
  );
};

export default Disclosure;


================================================
FILE: client/src/components/Disclosure.module.css
================================================
.Disclosure {
  display: flex;
  align-items: center;
  gap: 1px;
  transition: all 0.25s;
  cursor: pointer;
  color: var(--color-grey-secondary);
  font-size: var(--font-size-small);
}

.Disclosure:hover {
  color: var(--color-white-secondary);
}


================================================
FILE: client/src/components/Episode.jsx
================================================
import styles from "./Episode.module.css";
import Tag from "./Tag";

const Episode = ({ variant, name, number, date, isNew, isOriginalLength }) => {
  // eslint-disable-next-line no-unused-vars
  let [_, ...guest] = name.split("-");
  guest = guest.join("-");

  return (
    <div className={styles.epContent}>
      <div className={styles.tagsWrapper}>
        {isNew && <Tag variant="new">new</Tag>}
        {variant === "shortened" && isOriginalLength && (
          <Tag
            variant="originalLength"
            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.">
            original length
          </Tag>
        )}
      </div>
      <div className={styles.epName}>
        {number ? (
          <>
            <span className={styles.epNumber}>#{number}</span>
            <span className={styles.epGuest}>{guest}</span>
          </>
        ) : (
          name
        )}
      </div>
      {date && (
        <span className={styles.timeDetail}>
          {variant === "removed" ? "Removed" : "Shortened"} on{" "}
          <time dateTime={date.htmlAttribute}>{date.formatted}</time>
        </span>
      )}
    </div>
  );
};

export default Episode;


================================================
FILE: client/src/components/Episode.module.css
================================================
.epContent {
  display: flex;
  flex-direction: column;
}

.tagsWrapper {
  display: flex;
  gap: 9px;
  margin-bottom: 8px;
}

.epName {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.epNumber {
  color: var(--color-orange);
}

.epGuest {
  text-align: center;
}

.timeDetail {
  font-size: var(--font-size-small);
  letter-spacing: 0.1px;
  color: var(--color-grey);
}

@media (max-width: 1350px) {
  .EpisodeItem {
    display: unset;
    text-align-last: center;
    align-items: unset;
    width: 100%;
  }

  .epContent {
    align-items: center;
  }

  .epName {
    justify-content: center;
    gap: 0.5rem;
  }

  .timeDetail {
    display: block;
    margin: 4px 0 0 0;
    font-size: var(--font-size-small);
  }
}


================================================
FILE: client/src/components/EpisodeList.jsx
================================================
import classnames from "classnames";
import styles from "./EpisodeList.module.css";
import Episode from "./Episode";
import SkeletonList from "../skeletons/SkeletonList.jsx";
import ListTabs from "./ListTabs";
import ChangeDetails from "./ChangeDetails";

const EpisodeList = ({
  missingEpisodesShown,
  shortenedEpisodesShown,
  shouldShake,
  showSkeleton,
  searchText,
  listShown,
  setListShown,
  resetCurrentEpisodes,
}) => {
  if (showSkeleton) return <SkeletonList />;
  if (!missingEpisodesShown && !shortenedEpisodesShown) return null;

  const classesEpList = classnames(styles.EpisodeList, {
    shake: shouldShake,
  });

  const listIdRemoved = "episode-list-removed";
  const listIdShortened = "episode-list-shortened";
  const tabIdRemoved = "tab-removed";
  const tabIdShortened = "tab-shortened";
  return (
    <div className={styles.wrapper}>
      <ListTabs
        listShown={listShown}
        setListShown={setListShown}
        resetCurrentEpisodes={resetCurrentEpisodes}
        listIdRemoved={listIdRemoved}
        listIdShortened={listIdShortened}
        tabIdRemoved={tabIdRemoved}
        tabIdShortened={tabIdShortened}
      />
      <>
        {listShown === "removed" ? (
          <RemovedList
            searchText={searchText}
            episodes={missingEpisodesShown}
            className={classesEpList}
            id={listIdRemoved}
            ariaLabelledBy={tabIdRemoved}
          />
        ) : (
          <ShortenedList
            searchText={searchText}
            episodes={shortenedEpisodesShown}
            className={classesEpList}
            id={listIdShortened}
            ariaLabelledBy={tabIdShortened}
          />
        )}
      </>
    </div>
  );
};

const RemovedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {
  return (
    <ul className={className} role="tabpanel" id={id} aria-labelledby={ariaLabelledBy}>
      {episodes.length > 0
        ? episodes.map((ep) => (
            <li
              className={styles.EpisodeItem}
              key={ep.full_name + ep.episode_number}
              lang="en">
              <Border />
              <Episode
                variant="removed"
                name={ep.full_name}
                number={ep.episode_number}
                date={ep.date}
                isNew={ep.isNew}
              />
            </li>
          ))
        : !searchText && (
            <div className={styles.NoEpisodesMessage}>
              No episodes have been removed yet. Check back later!
            </div>
          )}
    </ul>
  );
};

const ShortenedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {
  return (
    <ul className={className} role="tabpanel" id={id} aria-labelledby={ariaLabelledBy}>
      {episodes.length > 0
        ? episodes.map((ep, i) => {
            return (
              <li
                className={classnames(styles.EpisodeItem, styles.shortenedEpisode)}
                key={ep.full_name + ep.episode_number}
                lang="en">
                {i !== 0 && <Border />}
                <Episode
                  variant="shortened"
                  name={ep.full_name}
                  number={ep.episode_number}
                  date={ep.changes[0].date}
                  isNew={ep.isNew}
                  isOriginalLength={ep.isOriginalLength}
                />
                <ChangeDetails episode={ep} />
              </li>
            );
          })
        : !searchText && (
            <div className={styles.NoEpisodesMessage}>
              No episodes have been shortened yet. Check back later!
            </div>
          )}
    </ul>
  );
};

const Border = ({ visible }) => {
  return (
    <span
      className={classnames(styles.Border, {
        [styles.visible]: visible,
      })}></span>
  );
};

export default EpisodeList;


================================================
FILE: client/src/components/EpisodeList.module.css
================================================
.wrapper .EpisodeList {
  position: unset;
  font-size: 1.4rem;
  list-style-type: none;
  display: flex;
  flex-direction: column;
  gap: 4.5rem;
}

.EpisodeItem {
  font-size: var(--font-size-medium);
  hyphens: auto;
  position: relative;
  display: flex;
  flex-direction: column;
}

.NoEpisodesMessage {
  color: var(--color-grey);
}

.Border {
  display: initial;
  background-color: var(--color-grey);
  height: 1px;
  width: 75%;
  opacity: 0.25;
  margin-bottom: 4rem;
}

.Border:not(.visible) {
  display: none;
}

@media (max-width: 1350px) {
  .EpisodeList {
    gap: 3.5rem;
    justify-items: center;
  }
  .EpisodeItem {
    flex-direction: column;
    align-items: center;
  }

  .Border:not(.visible) {
    display: initial;
  }
}

@media (max-width: 600px) {
  .EpisodeList {
    font-size: 1.2rem;
    margin-right: 0.6rem;
  }
}


================================================
FILE: client/src/components/Error.jsx
================================================
import AlertIcon from "../icons/alertIcon.svg";
import styles from "./Error.module.css";

const Error = ({ error }) => {
  return (
    <div className={styles.error}>
      <AlertIcon className={styles.icon} />
      {error}
    </div>
  );
};

export default Error;


================================================
FILE: client/src/components/Error.module.css
================================================
.error {
  display: flex;
  align-items: center;
  font-size: var(--font-size-medium);
}

.icon {
  margin-right: 1rem;
  width: 2.4rem;
  height: 2.4rem;
}


================================================
FILE: client/src/components/Github.jsx
================================================
import GitHubLogo from "../icons/github-1.png";
import styles from "./Github.module.css";

const Github = () => {
  return (
    <>
      <a
        className={styles.Github}
        href="https://github.com/HenB13/jre-missing"
        target="_blank"
        rel="noopener noreferrer"
        aria-label="view code on GitHub">
        <img src={GitHubLogo} alt="github logo" className={styles.GithubLogo} />{" "}
        <span className={styles.GithubText}> {"<--"} View the code!</span>
      </a>
    </>
  );
};

export default Github;


================================================
FILE: client/src/components/Github.module.css
================================================
.Github {
  position: fixed;
  top: 4rem;
  left: 5rem;
  display: flex;
  align-items: center;
}

.GithubLogo {
  transition: transform 0.2s;
  height: 2.4rem;
  width: 2.4rem;
  margin-right: 1rem;
}

.Github:hover .GithubLogo {
  transform: scale(1.2);
  cursor: pointer;
}

.Github:active .GithubLogo {
  transform: scale(1);
}

.GithubText {
  font-size: var(--font-size-medium);
  font-style: italic;
  color: var(--color-white);
}

@media (max-width: 1350px) {
  .Github {
    position: absolute;
  }

  .GithubText {
    display: none;
  }
}

@media (max-width: 750px) {
  .Github {
    top: 2rem;
    left: 2rem;
  }
}

@media (max-width: 380px) {
  .Github {
    display: none;
  }
}


================================================
FILE: client/src/components/Header.jsx
================================================
import styles from "./Header.module.css";

const Header = () => {
  return (
    <header className={styles.Header}>
      <h1>
        <span>JRE</span> MISSING
      </h1>
      <p className={styles.intro}>
        This website automatically detects episodes of{" "}
        <a
          href="https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk"
          target="_blank"
          rel="noopener noreferrer">
          <span>The Joe Rogan Experience</span>
          <br /> podcast{" "}
        </a>{" "}
        that are currently not available on the Spotify platform by comparing the official
        Spotify API with a database of all episodes ever released. It also detects if episodes
        have been shortened in duration.
      </p>
    </header>
  );
};

export default Header;


================================================
FILE: client/src/components/Header.module.css
================================================
.Header {
  margin-bottom: 10rem;
}

h1 {
  font-size: 8rem;

  font-family: "Teko", sans-serif;
  font-style: italic;
  letter-spacing: -3px;
  font-weight: 900;
  margin-left: -2px;
  color: var(--color-red);
}

h1 span {
  color: var(--color-white);
}

.intro {
  font-size: var(--font-size-medium);
  max-width: 47rem;
}

.intro span {
  color: var(--color-orange);
  font-weight: 600;
  transition: color 0.75s;
}

.intro span {
  position: relative;
}

.intro span:after {
  content: "";
  position: absolute;
  bottom: 0.05em;
  left: 0;
  width: 100%;
  height: 1px;
  min-height: 1px;
  z-index: -1;
  background-color: var(--color-orange);
  transform: scale(0, 1);
  transition: transform 0.25s ease;
}

.intro span:hover:after {
  transform: scale(1, 1);
}

.brMobile {
  display: none;
}

@media (max-width: 1350px) {
  .Header {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 15rem;
    margin-bottom: 0;
  }
  h1 {
    font-size: clamp(3rem, 5rem, 5vw);
    letter-spacing: unset;
    margin-bottom: 2rem;
  }
  .intro {
    text-align: center;
    font-size: clamp(1.4rem, 1.6rem, 2.5vw);
    max-width: 40rem;
  }

  .intro a {
    display: block;
    width: max-content;
    margin: 0 auto;
  }
  br {
    display: none;
  }
}

@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {
  .Header {
    margin-top: 10rem;
  }
}

@media (orientation: landscape) AND (max-height: 650px) {
  .Header {
    margin-top: 11rem;
  }
}


================================================
FILE: client/src/components/ListTabs.jsx
================================================
import styles from "./ListTabs.module.css";
import classnames from "classnames";

const ListTabs = ({
  listShown,
  setListShown,
  resetCurrentEpisodes,
  listIdRemoved,
  listIdShortened,
  tabIdRemoved,
  tabIdShortened,
}) => {
  return (
    <div className={styles.ListTab} role="tablist" aria-orientation="horizontal">
      <Option
        title="Removed"
        isSelected={listShown === "removed"}
        onClick={() => {
          setListShown("removed");
          resetCurrentEpisodes();
        }}
        id={tabIdRemoved}
        ariaControls={listIdRemoved}
      />
      <div className={styles.divider}></div>
      <Option
        title="Shortened"
        isSelected={listShown === "shortened"}
        onClick={() => {
          setListShown("shortened");
          resetCurrentEpisodes();
        }}
        id={tabIdShortened}
        ariaControls={listIdShortened}
      />
    </div>
  );
};

const Option = ({ title, onClick, isSelected, ariaControls, id }) => {
  return (
    <button
      id={id}
      className={classnames(styles.option, {
        [styles.selected]: isSelected,
      })}
      onClick={onClick}
      role="tab"
      aria-selected={isSelected}
      aria-controls={ariaControls}
      type="button">
      {title}
    </button>
  );
};

export default ListTabs;


================================================
FILE: client/src/components/ListTabs.module.css
================================================
.ListTab {
  font-size: var(--font-size-small);
  display: flex;
  gap: 2rem;
  margin-bottom: 4rem;
}

.option {
  transition: color, border-color 0.25s;
  padding-bottom: 0.5rem;
  color: var(--color-grey);
  transition: all 0.25s ease-out;
  border-bottom: 1px solid;
  border-color: var(--color-black);
}

.option.selected {
  border-color: var(--color-white);
}

.option.selected,
.option:hover {
  color: var(--color-white);
}

.divider {
  max-height: 50%;
  width: 1px;
  background-color: var(--color-grey);
  opacity: 0.3;
}

@media (max-width: 1350px) {
  .ListTab {
    justify-content: center;
    margin-bottom: 5rem;
  }
}


================================================
FILE: client/src/components/ScrollButton.jsx
================================================
import TextTransition, { presets } from "react-text-transition";
import classnames from "classnames";
import ArrowDown from "../icons/ScrollButtonIcon.svg";
import styles from "./ScrollButton.module.css";

const ScrollButton = ({ dataPending, minLoadingTimeElapsed, scrollTarget, scrollable }) => {
  const shouldHide = !scrollable || dataPending || !minLoadingTimeElapsed;

  function handleClick() {
    window.scroll({
      top: scrollTarget === "top" ? 0 : document.body.clientHeight,
      left: 0,
      behavior: "smooth",
    });
  }

  return (
    <button
      className={classnames(styles.ScrollButton, {
        [styles.up]: scrollTarget === "top",
        [styles.hidden]: shouldHide,
      })}
      disabled={shouldHide}
      aria-label={`scroll to ${scrollTarget}`}
      onClick={handleClick}>
      <div className={styles.ScrollText}>
        To{" "}
        <TextTransition
          springConfig={presets.gentle}
          inline={true}
          direction={scrollTarget === "top" ? "up" : "down"}>
          {scrollTarget}
        </TextTransition>
      </div>
      <ArrowDown
        className={classnames(styles.arrow, {
          [styles.up]: scrollTarget === "top",
        })}
      />
    </button>
  );
};

export default ScrollButton;


================================================
FILE: client/src/components/ScrollButton.module.css
================================================
.ScrollButton {
  background: transparent;
  outline: none;
  color: var(--color-white);
  font-family: var(--font-family-primary);
  position: fixed;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  bottom: 9rem;
  right: var(--number-right-placement);
  width: 9.5rem;
  height: 9.5rem;
  border: 1px solid var(--color-red);
  border-radius: 100%;
  font-size: 2rem;
  cursor: pointer;
  transition: transform 0.7s ease-out 0.2s, right 0.3s ease-out;
}

.ScrollButton.hidden {
  right: -10rem;
}

.ScrollText {
  z-index: 1;
}

.arrow {
  transition: all 0.5s ease-out;
  margin-bottom: 14px;
}

.arrow.up {
  transform: rotateZ(-180deg);
}

.ScrollButton:hover .arrow,
.ScrollButton:hover,
.ScrollButton:active {
  transform: translateY(5px);
}

.ScrollButton:focus-visible {
  outline: -webkit-focus-ring-color auto 1px;
}

.ScrollButton.up:hover .arrow {
  transform: translateY(-5px) rotateZ(-180deg);
}

.ScrollButton.up:hover,
.ScrollButton.up:active {
  transform: translateY(-5px);
}

@media (max-width: 1750px) {
  .ScrollButton {
    right: 4vw;
  }
}

@media (max-width: 1350px) {
  .ScrollButton {
    right: 10%;
  }
}

@media (max-width: 750px), (orientation: landscape) AND (max-height: 650px) {
  .ScrollButton {
    justify-content: center;
    width: 6.5rem;
    height: 6.5rem;
    bottom: 5%;
    right: 4%;
  }

  .ScrollButton .ScrollText {
    display: none;
  }

  .arrow {
    margin: 0;
  }

  .arrow path {
    stroke-width: 2px;
  }
}

@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {
  .ScrollButton {
    justify-content: center;
    width: 5rem;
    height: 5rem;
    bottom: 3%;
    right: 5%;
  }

  .arrow {
    width: 3rem;
    height: 3rem;
  }
}


================================================
FILE: client/src/components/Searchbox.jsx
================================================
import classnames from "classnames";
import { useState } from "react";
import styles from "./Searchbox.module.css";
import SearchIcon from "../icons/SearchboxIcon.svg";

//Rewrite the Searchbox component below but without forwardRef
const Searchbox = ({
  episodes,
  setEpisodes,
  allEpisodes,
  shakeEpisodes,
  searchText,
  setSearchText,
}) => {
  const [placeholder, setPlaceholder] = useState("Search for episode or guest");
  // TODO: useeffect isteden som setter episode ved tab change
  const handleSearch = (e) => {
    setEpisodes(() => {
      return allEpisodes.filter((ep) =>
        ep.full_name?.toLowerCase().includes(e.target.value.toLowerCase())
      );
    });
    setSearchText(e.target.value);
  };

  const classesSearchIcon = classnames(styles.SearchIcon, {
    [styles.hoverCursor]: searchText,
  });

  return (
    <>
      <div className={styles.Searchbox}>
        <input
          value={searchText}
          onChange={handleSearch}
          type="text"
          id="search"
          placeholder={placeholder}
          onFocus={() => setPlaceholder(null)}
          onBlur={() => setPlaceholder("Search for episode or guest")}
          onKeyUp={(e) => {
            if (e.key === "Enter") shakeEpisodes();
          }}
          spellCheck="false"
          autoComplete="off"
        />

        <SearchIcon
          className={classesSearchIcon}
          title="search-icon"
          onClick={() => {
            if (searchText) {
              shakeEpisodes();

              navigator.vibrate();
            }
          }}
        />
      </div>
      {searchText && (
        <p className={styles.searchResult}>
          {episodes.length} result
          {episodes.length != 1 && "s"} found
        </p>
      )}
    </>
  );
};

export default Searchbox;


================================================
FILE: client/src/components/Searchbox.module.css
================================================
.Searchbox {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border: 1px solid var(--color-grey-secondary);
  border-radius: 1.6rem;
  padding: 0 1.5rem;
  width: 23.3rem;
  height: 4.6rem;
  transition: background-color 0.35s;
  position: relative;
}

.Searchbox:focus-within {
  background-color: var(--color-black-secondary);
}

input {
  flex: 1;
  background-color: transparent;
  border: none;
  font-size: var(--font-size-small);
  outline: none;
  color: var(--color-grey-secondary);
  caret-color: var(--color-white);
  font-family: var(--font-family-primary);
}

input::-webkit-input-placeholder {
  position: absolute;
  color: var(--color-grey-secondary);
  opacity: 1;
  font-family: var(--font-family-primary);
  font-size: 1.2rem;

  transform: translateY(-12%);
  cursor: text;
  transition: all 0.15s ease-out;
}

.searchResult {
  color: var(--color-grey-secondary);
  font-size: 1.2rem;
  margin-top: 1rem;
  margin-left: 1.5rem;
}

.SearchIcon.hoverCursor {
  cursor: pointer;
}


================================================
FILE: client/src/components/Sort.jsx
================================================
import { useEffect } from "react";
import Arrow from "../icons/arrow.svg";
import { useState } from "react";
import classnames from "classnames";
import styles from "./Sort.module.css";
import Disclosure from "./Disclosure";
import Chavron from "../icons/chavron.svg";

const options = {
  removed: ["episode number", "date removed"],
  shortened: ["episode number", "date shortened"],
};

const initialState = { name: "episode number", reverse: false };

const Sort = ({ setEpisodes, episodes, listShown }) => {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(initialState);

  useEffect(() => {
    setSelected(initialState);
  }, [listShown]);

  const handleSort = (option, isReversed) => {
    let nulls;
    let nonNulls;
    if (option === "date shortened") {
      nulls = [];
      nonNulls = nonNulls = episodes.sort((a, b) => {
        [a, b] = isReversed ? [a, b] : [b, a];
        return a.changes[0].date.ms - b.changes[0].date.ms;
      });
    } else if (option === "date removed") {
      nulls = episodes.filter((ep) => !ep.date);
      nonNulls = episodes
        .filter((ep) => ep.date)
        .sort((a, b) => {
          [a, b] = isReversed ? [a, b] : [b, a];
          return a.date.ms - b.date.ms;
        });
      // episode number
    } else {
      nulls = episodes.filter((ep) => !ep.episode_number);
      nonNulls = episodes
        .filter((ep) => ep.episode_number)
        .sort((a, b) => {
          [a, b] = isReversed ? [a, b] : [b, a];
          return a.episode_number - b.episode_number;
        });
    }

    setEpisodes([...nonNulls, ...nulls]);
  };

  const disclosureId = "sort-by-toggle";
  const optionsWrapperId = "sort-by-content";

  return (
    <div
      className={classnames(styles.sort, {
        [styles.open]: open,
      })}>
      <Disclosure
        className={styles.sortDisclosure}
        isOpen={open}
        onClick={() => setOpen((open) => !open)}
        id={disclosureId}
        ariaControls={optionsWrapperId}>
        Sort by
        <Chavron
          className={classnames(styles.Chavron, {
            [styles.open]: open,
          })}
        />
      </Disclosure>
      <div
        role="listbox"
        className={styles.optionsWrapper}
        id={optionsWrapperId}
        aria-labelledby={disclosureId}
        aria-expanded={open}>
        {options[listShown]?.map((option) => {
          return (
            <Option
              optionName={option}
              key={option}
              handleSort={handleSort}
              selected={selected}
              setSelected={setSelected}
            />
          );
        })}
      </div>
    </div>
  );
};

function Option({ optionName, selected, setSelected, handleSort }) {
  const isSelected = selected.name === optionName;
  const isReversed = isSelected && selected.reverse;
  function handleClick() {
    const newReverse = isSelected ? !isReversed : isReversed;
    setSelected({ name: optionName, reverse: newReverse });
    handleSort(optionName, newReverse);
  }

  return (
    <button
      role="option"
      aria-selected={isSelected}
      aria-labelledby="option-label"
      className={classnames(styles.option, {
        [styles.selected]: isSelected,
      })}
      onClick={handleClick}>
      <div className={styles.label} id="option-label">
        {optionName
          .split(" ")
          .map((word) => word[0].toUpperCase() + word.slice(1))
          .join(" ")}
      </div>

      <Arrow
        className={classnames(styles.icon, {
          [styles.iconReverse]: isReversed,
        })}
      />
    </button>
  );
}

export default Sort;


================================================
FILE: client/src/components/Sort.module.css
================================================
.sort {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  color: var(--color-grey-secondary);
  margin-right: 2.5rem;
  margin-top: 6rem;
  font-size: var(--font-size-small);
}

.sortDisclosure {
  gap: 3px;
}

.optionsWrapper {
  display: flex;
  flex-direction: column;
  margin-top: 1.5rem;
}

.sort.open .optionsWrapper {
  cursor: pointer;
}

.option {
  font-size: 11px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  color: var(--color-grey-secondary);
  padding: 0.3em 0.6em 0.3em 1em;
  border: 1px solid var(--color-grey-secondary);
  transition: color, border-color 0.25s;
}

.option {
  visibility: hidden;
}

.sort.open .option {
  visibility: visible;
}

.option .label {
  margin-right: 2px;
  transition: color 0.5s;
}

.option.selected {
  border-color: var(--color-white);
}
.option:not(.selected) {
  transition: background-color 0.5s;
}

.option:hover:not(.selected) {
  background-color: var(--color-black-secondary);
}

.icon {
  transform: scale(0.6);
  transition: color, transform 0.25s ease-out;
}

.icon path {
  color: var(--color-grey);
  transition: color 0.5s;
  transition-delay: 0.25;
}

.option.selected .label,
.option.selected:hover .icon path {
  color: var(--color-white);
}

.option.selected:hover .label {
  color: var(--color-grey-secondary);
}

.iconReverse {
  transform: rotate(180deg) scale(0.6);
}

.Chavron {
  transform: rotate(180deg) scale(0.65);
  margin-top: 3px;
  transition: transform 0.25s ease-out;
}

.Chavron.open {
  transform: rotate(0deg) scale(0.65);
  margin-top: 3px;
}

@media (max-width: 1350px) {
  .sort {
    margin: 0;
    justify-content: center;
    align-items: center;
  }

  .sort.open {
    margin-bottom: 4rem;
  }
}


================================================
FILE: client/src/components/Sponsor.jsx
================================================
import styles from "./Sponsor.module.css";

const Sponsor = () => {
  return (
    <iframe
      src="https://github.com/sponsors/henb13/button"
      title="Sponsor henb13"
      className={styles.githubSponsorButton}></iframe>
  );
};

export default Sponsor;


================================================
FILE: client/src/components/Sponsor.module.css
================================================
.githubSponsorButton {
  position: fixed;
  top: 3.55rem;
  left: 20rem;
  height: 35px;
  width: 116px;
  border: 0;
  margin-left: 2rem;
  cursor: pointer;
}

@media (max-width: 1350px) {
  .githubSponsorButton {
    left: 8rem;
    position: absolute;
  }
}

@media (max-width: 750px) {
  .githubSponsorButton {
    top: 1.6rem;
    left: 5rem;
  }
}

@media (max-width: 380px) {
  .githubSponsorButton {
    top: 1.5rem;
    left: 0;
  }
}


================================================
FILE: client/src/components/Tag.jsx
================================================
import classnames from "classnames";
import { Tooltip } from "react-tooltip";
import styles from "./Tag.module.css";

const variantClasses = {
  new: styles.new,
  originalLength: styles.originalLength,
};

const Tag = ({ className, variant, children, toolTip }) => (
  <span className={classnames(styles.tag, className, variantClasses[variant])}>
    <span className={styles.tagName}>{children}</span>
    {toolTip && (
      <>
        <span
          data-tooltip-id="my-tooltip"
          data-tooltip-content={toolTip}
          className={styles.toolTip}>
          &#63;
        </span>
        <Tooltip id="my-tooltip" clickable className={styles.toolTipElement} />
      </>
    )}
  </span>
);

export default Tag;


================================================
FILE: client/src/components/Tag.module.css
================================================
.tag {
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 11px;
  color: var(--color-black);
  font-weight: 700;
  line-height: 100%;
  width: min-content;
}

.tagName {
  white-space: nowrap;
  border-radius: 10px;
  padding: 2px 8px 4px;
}

.new .tagName {
  background-color: var(--color-green);
}

.originalLength .tagName {
  background-color: var(--color-orange);
}

.toolTip {
  font-size: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 100%;
  background-color: transparent;
  border: 1px solid;
  width: 2px;
  height: 2px;
  padding: 7px;
  cursor: pointer;
  margin-left: 6px;
  margin-bottom: 1px;
  color: var(--color-grey);
  border-color: var(--color-grey);
}

.toolTipElement {
  max-width: 250px;
  font-family: var(--font-family-secondary);
  line-height: 16px;
  font-weight: normal;
  opacity: 0.95 !important;
}


================================================
FILE: client/src/hooks/useFetch.js
================================================
import { useState, useEffect } from "react";

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [isPending, setIsPending] = useState(true);

  const [error, setError] = useState(null);

  useEffect(() => {
    const abortCont = new AbortController();
    fetch(url, { signal: abortCont.signal })
      .then((res) => {
        if (!res.ok) {
          throw Error(
            res.status == 429
              ? "You are making too many requests, please try again later."
              : "Something went wrong, please try again later."
          );
        }
        return res.json();
      })
      .then((data) => {
        setIsPending(false);
        setError(null);
        setData(data);
      })
      .catch((err) => {
        if (err.name === "AbortError") {
          console.warn("fetch aborted");
        } else {
          setIsPending(false);
          console.error(`Something went wrong with fetching episodes: ${err}`);
          setError(err.message);
        }
      });

    return () => abortCont.abort();
  }, [url]);

  return { data, isPending, error };
};

export default useFetch;


================================================
FILE: client/src/hooks/useMinLoadingTime.js
================================================
import { useState, useEffect } from "react";

const useMinLoadingTime = (minTime) => {
  const [minLoadingTimeElapsed, setMinLoadingTimeElapsed] = useState(true);

  useEffect(() => {
    setMinLoadingTimeElapsed(false);

    const timer = setTimeout(() => {
      setMinLoadingTimeElapsed(true);
    }, minTime);

    return () => {
      clearTimeout(timer);
    };
  }, [minTime]);

  return minLoadingTimeElapsed;
};

export default useMinLoadingTime;


================================================
FILE: client/src/hooks/useScroll.js
================================================
import { useState, useEffect } from "react";
import _ from "lodash";

const useScroll = ({
  refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],
}) => {
  const [scrollTarget, setScrollTarget] = useState("bottom");
  const [scrollable, setScrollable] = useState(null);

  useEffect(() => {
    setScrollable(document.body.clientHeight > window.innerHeight);
  }, [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText, setScrollable]);

  useEffect(() => {
    const handleScroll = _.throttle(() => {
      setScrollTarget(
        window.pageYOffset + window.innerHeight / 2 > document.body.clientHeight / 2
          ? "top"
          : "bottom"
      );
    }, 200);

    const handleResize = _.throttle(() => {
      handleScroll();
      setScrollable(document.body.clientHeight > window.innerHeight);
    }, 200);

    window.addEventListener("scroll", handleScroll, {
      passive: true,
    });
    window.addEventListener("resize", handleResize, {
      passive: true,
    });

    return () => {
      window.removeEventListener("scroll", handleScroll);
      window.removeEventListener("resize", handleResize);
    };
  }, [scrollTarget]);

  return { scrollTarget, scrollable, setScrollable };
};

export default useScroll;


================================================
FILE: client/src/index.css
================================================
@import url("https://fonts.googleapis.com/css2?family=Oswald:wght@200;300;400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Teko:wght@700&display=swap");

:root {
  --color-black-secondary: #0f0f0f;
  --color-black: #151515;
  --color-grey: #424242;
  --color-grey-secondary: #6b6b6b;
  --color-white: #fafafa;
  --color-white-secondary: #e8e6e6;
  --color-orange: #ee7d2c;
  --color-red: #a51f21;
  --color-green: #1ed760;

  --font-size-large: 2.2rem;
  --font-size-medium: 1.6rem;
  --font-size-small: 1.2rem;
  --font-size-xs: 1rem;

  --font-family-primary: Oswald, sans-serif;
  --font-family-secondary: Roboto, sans-serif;

  --number-right-placement: 6%;
}

*,
:after,
:before {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

html {
  font-size: 62.5%;
  font-family: var(--font-family-primary);
  overflow-y: scroll;
}

body {
  background-color: var(--color-black);
  color: var(--color-white);
  min-height: 100vh;
}

a,
a:link,
a:visited {
  color: inherit;
  text-decoration: none;
}

input,
textarea,
button,
select,
a {
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

input,
button,
textarea,
select {
  font: inherit;
}

button {
  background: none;
  color: inherit;
  border: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
  outline: inherit;
}

button:hover {
  cursor: pointer;
}

button:focus-visible {
  outline: -webkit-focus-ring-color auto 2px;
}

.shake {
  animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}

@keyframes shake {
  10%,
  90% {
    transform: translate3d(-1px, 0, 0);
  }

  20%,
  80% {
    transform: translate3d(2px, 0, 0);
  }

  30%,
  50%,
  70% {
    transform: translate3d(-4px, 0, 0);
  }

  40%,
  60% {
    transform: translate3d(4px, 0, 0);
  }
}


================================================
FILE: client/src/index.jsx
================================================
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./components/App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);


================================================
FILE: client/src/skeletons/SkeletonList.jsx
================================================
import { useRef } from "react";
import styles from "./SkeletonStyles.module.css";

const SkeletonList = () => {
  const stylesArr = useRef(
    Array.apply(null, Array(42)).map(() => {
      const random = Math.random();
      const type =
        random > 90
          ? styles.extraLarge
          : random > 0.85
          ? styles.large
          : random > 0.75
          ? styles.medium
          : random > 0.15
          ? styles.small
          : styles.extraSmall;

      return type;
    })
  );

  return (
    <div className={`${styles.SkeletonList}`}>
      {stylesArr.current?.map((st, i) => {
        return (
          <div key={i} className={`${styles.SkeletonElement} ${styles.listElement} ${st}`}>
            <div className={styles.SkeletonShimmerWrapper}>
              <div className={styles.SkeletonShimmer}></div>
            </div>
          </div>
        );
      })}
    </div>
  );
};

export default SkeletonList;


================================================
FILE: client/src/skeletons/SkeletonStyles.module.css
================================================
.SkeletonList {
  position: unset;
  font-size: 1.4rem;
  list-style-type: none;
  display: flex;
  flex-direction: column;
  gap: 4.5rem;
  margin-top: 6.5rem;
}

.SkeletonElement {
  position: relative;
  overflow: hidden;
  background-color: #212121;
  border-radius: 2px;
}

.hidden {
  visibility: hidden;
}

.listElement {
  height: 20px;
}

.SkeletonElement.extraSmall {
  width: 105px;
}

.SkeletonElement.small {
  width: 150px;
}

.SkeletonElement.medium {
  width: 220px;
}

.SkeletonElement.large {
  width: 260px;
}

.SkeletonElement.extraLarge {
  width: 325px;
}

.SkeletonShimmerWrapper {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.SkeletonShimmer {
  width: 50%;
  height: 100%;
  background: rgba(0, 0, 0, 0.1);
  transform: skewX(-50deg);
  box-shadow: 0 0 60px 60px rgba(0, 0, 0, 0.1);
  animation: loading 2s infinite;
}

@keyframes loading {
  0% {
    transform: translateX(-20vw);
  }
  50% {
    transform: translateX(3vw);
  }
  100% {
    transform: translateX(20vw);
  }
}

.skeletonText {
  display: flex;
  flex-direction: column;
  margin-top: 10.5rem;
  margin-bottom: 2.5rem;
}

.amount {
  position: relative;
  height: 24px;
  width: 280px;
}

.shortened {
  margin-top: 10px;
  height: 22px;
  width: 240px;
}

.lastChecked {
  position: relative;
  margin-top: 7px;
  height: 19px;
  width: 250px;
}

@media (max-width: 1350px) {
  .SkeletonList {
    justify-content: center;
    align-items: center;
    margin-top: 0;
    gap: 9.15rem;
  }

  .SkeletonElement.extraSmall,
  .SkeletonElement.small,
  .SkeletonElement.medium,
  .SkeletonElement.large,
  .SkeletonElement.extraLarge {
    width: 130px;
    height: 25px;
  }

  .skeletonText {
    margin-top: 4.7rem;
    align-items: center;
    margin-bottom: 48px;
  }

  .amount {
    width: 250px;
    height: 22px;
    margin-left: 2px;
  }

  .shortened {
    width: 220px;
    margin-top: 7px;
    height: 21px;
  }

  .lastChecked {
    height: 18px;
    width: 150px;
    margin-top: 10px;
  }
}


================================================
FILE: client/src/skeletons/SkeletonText.jsx
================================================
import classnames from "classnames";
import styles from "./SkeletonStyles.module.css";

const SkeletonElement = ({ elStyle }) => {
  return (
    <div className={`${styles.SkeletonElement} ${elStyle}`}>
      <div className={styles.SkeletonShimmerWrapper}>
        <div className={styles.SkeletonShimmer}></div>
      </div>
    </div>
  );
};

const SkeletonText = () => {
  return (
    <div className={styles.skeletonText}>
      <SkeletonElement elStyle={styles.amount} />
      <SkeletonElement elStyle={classnames(styles.amount, styles.shortened)} />
      <SkeletonElement elStyle={styles.lastChecked} />
    </div>
  );
};

export default SkeletonText;


================================================
FILE: client/src/utils.js
================================================
import { zonedTimeToUtc, utcToZonedTime, format as formatTz } from "date-fns-tz";

export const getClientLocalTime = (date, pattern) => {
  const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const utcDate = zonedTimeToUtc(date, userTimezone);
  const zonedDate = utcToZonedTime(utcDate, userTimezone);
  const lastCheckedDate = formatTz(zonedDate, pattern, {
    timeZone: userTimezone,
  });
  return lastCheckedDate;
};

export const getDateString = (time) => {
  return getClientLocalTime(time, "PPP");
};

export const formatMinutesToTimeAmountString = (minutes) => {
  if (minutes < 1) {
    return "less than a minute ago";
  }

  if (minutes < 60) {
    const minutesString = setPlurality("minute", minutes);
    return `${minutes} ${minutesString}`;
  }
  if (minutes < 1440) {
    const hours = Math.floor(minutes / 60);
    const hoursString = setPlurality("hour", hours);
    const minutesRest = minutes % 60;
    const minutesString = setPlurality("minute", minutesRest);

    return `${hours} ${hoursString}${
      minutesRest === 0 ? "" : ` and ${minutesRest} ${minutesString}`
    }`;
  }

  const days = Math.floor(minutes / (60 * 24));
  const daysString = days === 1 ? "day" : "days";
  return `${days} ${daysString}`;
};

const setPlurality = (text, number) => {
  return `${text}${number === 1 ? "" : "s"}`;
};


================================================
FILE: client/src/utils.test.js
================================================
import { formatMinutesToTimeAmountString } from "./utils";

describe("get correct timestring given a minute amount as input", () => {
  test("return 'less than a minute ago'", () => {
    expect(formatMinutesToTimeAmountString(0)).toBe("less than a minute ago");
    expect(formatMinutesToTimeAmountString(0.5)).toBe("less than a minute ago");
  });

  test("return minutes", () => {
    expect(formatMinutesToTimeAmountString(1)).toBe("1 minute");
    expect(formatMinutesToTimeAmountString(30)).toBe("30 minutes");
    expect(formatMinutesToTimeAmountString(59)).toBe("59 minutes");
  });

  test("return hours", () => {
    expect(formatMinutesToTimeAmountString(60)).toBe("1 hour");
    expect(formatMinutesToTimeAmountString(60 * 2)).toBe("2 hours");
    expect(formatMinutesToTimeAmountString(60 * 23)).toBe("23 hours");
  });

  test("return days", () => {
    expect(formatMinutesToTimeAmountString(60 * 24)).toBe("1 day");
    expect(formatMinutesToTimeAmountString(60 * 24 * 2)).toBe("2 days");
    expect(formatMinutesToTimeAmountString(60 * 24 * 3)).toBe("3 days");
    expect(formatMinutesToTimeAmountString(60 * 24 * 76)).toBe("76 days");
  });
});

describe("get correct minutes addition to time string when returning hours (i.e. '2 hours and 5 minutes')", () => {
  test("return hours and minutes", () => {
    expect(formatMinutesToTimeAmountString(60 + 1)).toBe("1 hour and 1 minute");
    expect(formatMinutesToTimeAmountString(60 + 59)).toBe("1 hour and 59 minutes");
    expect(formatMinutesToTimeAmountString(60 * 2 + 5)).toBe("2 hours and 5 minutes");
    expect(formatMinutesToTimeAmountString(60 * 5 + 25)).toBe("5 hours and 25 minutes");
    expect(formatMinutesToTimeAmountString(60 * 6 + 1)).toBe("6 hours and 1 minute");
    expect(formatMinutesToTimeAmountString(60 * 6 + 2)).toBe("6 hours and 2 minutes");
  });
});


================================================
FILE: client/tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "noFallthroughCasesInSwitch": true,
    "jsx": "react-jsx",
    "types": ["vite/client", "vite-plugin-svgr/client"]
  },
  "include": ["src"]
}


================================================
FILE: client/vite-env.d.ts
================================================
/// <reference types="vite/client" /> /// <referencetypes="vite-plugin-svgr/client" />


================================================
FILE: client/vite.config.ts
================================================
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
import svgrPlugin from "vite-plugin-svgr";

// https://vitejs.dev/config/
export default defineConfig({
  base: "/",
  plugins: [
    react(),
    tsconfigPaths(),
    svgrPlugin({ svgrOptions: { icon: true }, include: "**/*.svg" }),
  ],
  server: {
    port: 3000,
  },
  test: {
    globals: true,
    environment: "jsdom",
    css: true,
    reporters: ["verbose"],
    coverage: {
      reporter: ["text", "json", "html"],
      include: ["src/**/*"],
      exclude: [],
    },
  },
});


================================================
FILE: server/.eslintrc.json
================================================
{
  "env": {
    "commonjs": true,
    "node": true,
    "es2021": true,
    "jest": true
  },
  "extends": ["eslint:recommended", "prettier"],
  "parserOptions": {
    "ecmaVersion": 12
  },
  "rules": {
    "prefer-const": [
      "warn",
      {
        "destructuring": "all",
        "ignoreReadBeforeAssign": false
      }
    ],
    "prefer-template": "warn"
  },
  "ignorePatterns": ["node_modules/", "coverage/"]
}


================================================
FILE: server/api/__mocks__/mockResponse.js
================================================
const missingEpisodes = [
  {
    full_name: "#374 - Marc Maron",
    episode_number: 374,
    isNew: false,
    date: {
      ms: 1643984238000,
      formatted: "February 4th, 2022",
      htmlAttribute: "2022-02-04",
    },
  },
  {
    full_name: "#320 - Tim Ferriss",
    episode_number: 320,
    isNew: false,
    date: {
      ms: 1644006629000,
      formatted: "February 4th, 2022",
      htmlAttribute: "2022-02-04",
    },
  },
];

const shortenedEpisodes = [
  {
    id: 2124,
    episode_number: 1877,
    full_name: "#1877 - Jann Wenner",
    isNew: true,
    isOriginalLength: false,
    changes: [
      {
        date: {
          ms: 1665010388000,
          formatted: "October 6th, 2022",
          htmlAttribute: "2022-10-06",
        },
        new_duration_string: "2 hr 51 min 39 sec",
        old_duration_string: "2 hr 51 min 46 sec",
      },
    ],
  },
  {
    id: 1943,
    episode_number: 1330,
    full_name: "#1330 - Bernie Sanders",
    isNew: false,
    isOriginalLength: false,
    changes: [
      {
        date: {
          ms: 1664486810000,
          formatted: "September 29th, 2022",
          htmlAttribute: "2022-09-29",
        },
        new_duration_string: "1 hr 17 min 9 sec",
        old_duration_string: "1 hr 51 min 46 sec",
      },
    ],
  },
  {
    id: 1389,
    episode_number: 1159,
    full_name: "#1159 - Neil deGrasse Tyson",
    isNew: true,
    isOriginalLength: true,
    changes: [
      {
        date: {
          ms: 1665005849000,
          formatted: "October 5th, 2022",
          htmlAttribute: "2022-10-05",
        },
        new_duration_string: "3 hr 21 min 8 sec",
        old_duration_string: "3 hr 21 min 3 sec",
      },
      {
        date: {
          ms: 1665005755000,
          formatted: "October 5th, 2022",
          htmlAttribute: "2022-10-05",
        },
        new_duration_string: "3 hr 21 min 3 sec",
        old_duration_string: "3 hr 21 min 8 sec",
      },
      {
        date: {
          ms: 1664919458000,
          formatted: "October 4th, 2022",
          htmlAttribute: "2022-10-04",
        },
        new_duration_string: "3 hr 21 min 6 sec",
        old_duration_string: "3 hr 21 min 7 sec",
      },
      {
        date: {
          ms: 1664919253000,
          formatted: "October 4th, 2022",
          htmlAttribute: "2022-10-04",
        },
        new_duration_string: "3 hr 21 min 7 sec",
        old_duration_string: "3 hr 21 min 8 sec",
      },
    ],
  },
  {
    id: 1340,
    episode_number: 1678,
    full_name: "#1678 - Michael Pollen",
    isNew: true,
    isOriginalLength: false,
    changes: [
      {
        date: {
          ms: 1665005849000,
          formatted: "October 5th, 2022",
          htmlAttribute: "2022-10-05",
        },
        new_duration_string: "3 hr 21 min 1 sec",
        old_duration_string: "3 hr 21 min 3 sec",
      },
      {
        date: {
          ms: 1665005755000,
          formatted: "October 5th, 2022",
          htmlAttribute: "2022-10-05",
        },
        new_duration_string: "3 hr 21 min 3 sec",
        old_duration_string: "3 hr 21 min 8 sec",
      },
      {
        date: {
          ms: 1664919458000,
          formatted: "October 4th, 2022",
          htmlAttribute: "2022-10-04",
        },
        new_duration_string: "3 hr 21 min 6 sec",
        old_duration_string: "3 hr 21 min 7 sec",
      },
      {
        date: {
          ms: 1664919253000,
          formatted: "October 4th, 2022",
          htmlAttribute: "2022-10-04",
        },
        new_duration_string: "3 hr 21 min 7 sec",
        old_duration_string: "3 hr 21 min 8 sec",
      },
    ],
  },
];

const lastCheckedInMs = 1665691453175;

const mockResponse = {
  missingEpisodes,
  shortenedEpisodes,
  lastCheckedInMs,
};

module.exports = { mockResponse };


================================================
FILE: server/api/dev-api.js
================================================
const express = require("express");
const router = express.Router();
const { mockResponse } = require("./__mocks__/mockResponse");

router.use(express.json());
require("dotenv").config();

router.get("/api/episodes", async (_, res) => {
  res.header({
    "Access-Control-Allow-Origin": "http://localhost:3000",
  });

  return res.json(mockResponse);

  //TODO: Implement dev database
});

module.exports = router;


================================================
FILE: server/api/index.js
================================================
const express = require("express");
const router = express.Router();
const DB = require("../db/db");
const pool = require("../db/connect");
const { mockResponse } = require("./__mocks__/mockResponse");

router.use(express.json());
require("dotenv").config();

let missingEpisodesCache;
let shortenedEpisodesCache;
let lastCheckedCache;

const { CRON_INTERVAL, USE_MOCK_DATA, NODE_ENV } = process.env;
const isDev = NODE_ENV === "development";

//TODO: Change when client in prod
const allowOrigin = isDev ? "http://localhost:3000" : "https://not-yet-prod-domain.com";

router.get("/api/episodes", async (_, res) => {
  if (isDev && USE_MOCK_DATA === "true") {
    return res.json(mockResponse);
  }

  const timeSinceLastCheckedDbInMins =
    lastCheckedCache && (Date.now() - lastCheckedCache) / 1000 / 60;

  const cacheTimeLeftSecs = Math.floor((CRON_INTERVAL - timeSinceLastCheckedDbInMins) * 60);
  const buffer = 120;

  res.header({
    "cache-control": `no-transform, max-age=${cacheTimeLeftSecs + buffer || 1}`,
    "Access-Control-Allow-Origin": allowOrigin,
  });

  if (
    !missingEpisodesCache ||
    !shortenedEpisodesCache ||
    timeSinceLastCheckedDbInMins > CRON_INTERVAL
  ) {
    await (async () => {
      const client = await pool.connect();
      const db = DB(client);
      try {
        missingEpisodesCache = await db.getMissingEpisodes();
        shortenedEpisodesCache = await db.getShortenedEpisodes();
        lastCheckedCache = await db.getLastChecked();
        console.info("db queried and cache updated");
      } finally {
        client.release();
      }
    })().catch((err) => console.error(err.message));
  }

  console.info("request fired");
  res.json({
    missingEpisodes: missingEpisodesCache,
    shortenedEpisodes: shortenedEpisodesCache,
    lastCheckedInMs: lastCheckedCache,
  });
});

module.exports = router;


================================================
FILE: server/app.js
================================================
const express = require("express");
const app = express();
const api = require("./api/index.js");
const devApi = require("./api/dev-api.js");
const rateLimit = require("express-rate-limit");
const slowDown = require("express-slow-down");
const helmet = require("helmet");
require("dotenv").config();

// eslint-disable-next-line no-undef
const port = process.env.PORT || 3001;
const useDevApi =
  process.env.NODE_ENV === "development" && process.env.USE_MOCK_DATA === "true";

app.set("trust proxy", 1);

const rateLimiter = rateLimit({
  windowMs: 15 * 1000,
  max: 7,
});

const speedLimiter = slowDown({
  windowMs: 15 * 1000,
  delayAfter: 3,
  delayMs: (hits) => hits * 100,
});

app.use(express.json());
app.use(helmet());
app.use(rateLimiter);
app.use(speedLimiter);
app.use(useDevApi ? devApi : api);

app.listen(port, () => {
  console.log(`Server listening on port ${port}!`);
});

module.exports = app;


================================================
FILE: server/db/connect.js
================================================
const pg = require("pg");
pg.types.setTypeParser(1184, (str) => str);
pg.defaults.poolSize = 25;

require("dotenv").config();

const pool = new pg.Pool();

module.exports = pool;


================================================
FILE: server/db/db.js
================================================
const { mapMissingEpisodes, mapShortenedEpisodes, mapLastChecked } = require("./mapQueries");

const getEpisodeNumber = require("../lib/getEpisodeNumber");

const DB = (client) => {
  return {
    getAllEpisodes: async function () {
      const { rows } = await client.query("SELECT * from all_eps");
      return rows;
    },
    getMissingEpisodes: async function () {
      const { rows } = await client.query(
        `SELECT full_name, EXTRACT(EPOCH FROM date_removed at time zone 'UTC') * 1000 AS date_removed, episode_number 
        FROM all_eps  
        LEFT JOIN (
          SELECT id, MAX(date_removed) AS date_removed
          from date_removed 
          group by id
        ) AS t2
        ON all_eps.id = t2.id 
        WHERE on_spotify = false
        ORDER BY episode_number DESC NULLS LAST, all_eps.id`
      );
      return mapMissingEpisodes(rows);
    },
    getShortenedEpisodes: async function () {
      const { rows } = await client.query(
        `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
        FROM all_eps
        JOIN (
          SELECT id, episode_id, new_duration, old_duration, date AS date_changed
          FROM duration_changes
          GROUP BY episode_id, id, old_duration
         ) AS t2
         ON all_eps.id = t2.episode_id
         ORDER BY date_changed DESC`
      );
      return mapShortenedEpisodes(rows);
    },
    insertNewEpisode: async function (episode) {
      const epNumber = getEpisodeNumber(episode.name);
      await client.query("INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)", [
        epNumber,
        episode.name,
        true,
        episode.duration,
      ]);
    },
    updateEpisodeName: async function (name, id) {
      await client.query("UPDATE all_eps SET full_name=($1) WHERE id=($2)", [name, id]);
    },
    updateEpisodeDuration: async function (newDuration, id) {
      await client.query("UPDATE all_eps SET duration=($1) WHERE id=($2)", [newDuration, id]);
    },
    setSpotifyStatus: async function ({ id }, bool) {
      await client.query(`UPDATE all_eps SET on_spotify=($1) WHERE id=($2)`, [bool, id]);
    },
    setLastCheckedNow: async function () {
      await client.query("UPDATE all_eps_log SET last_checked=now()");
    },
    getLastChecked: async function () {
      const { rows } = await client.query(
        `SELECT EXTRACT(EPOCH FROM last_checked at time zone 'UTC') * 1000 AS miliseconds 
        FROM all_eps_log`
      );
      return mapLastChecked(rows);
    },
  };
};

module.exports = DB;


================================================
FILE: server/db/mapQueries.js
================================================
const { differenceInDays, parseISO } = require("date-fns");
const {
  formatMsToTimeString,
  getDateString,
  getDateTimeHTMLAttribute,
} = require("../utils/utils");

const DAYS_THRESHOLD_NEW = 14;

const getIsEpisodeNewlyReleased = (time) =>
  time &&
  differenceInDays(new Date(), parseISO(new Date(time).toISOString())) < DAYS_THRESHOLD_NEW;

const mapMissingEpisodes = (missingEpisodes) => {
  return missingEpisodes.map((ep) => {
    const { full_name, episode_number, date_removed } = ep;
    const ms = parseInt(date_removed);

    return {
      full_name,
      episode_number,
      isNew: getIsEpisodeNewlyReleased(ms),
      date: ms
        ? {
            ms,
            formatted: getDateString(ms),
            htmlAttribute: getDateTimeHTMLAttribute(ms),
          }
        : null,
    };
  });
};

const mapShortenedEpisodes = (shortenedEpisodes) => {
  return shortenedEpisodes
    .reduce((acc, curr) => {
      const { id, episode_number, full_name, date_changed, new_duration, old_duration } = curr;

      const ms = parseInt(date_changed);

      const changeItem = {
        date: {
          ms,
          formatted: getDateString(ms),
          htmlAttribute: getDateTimeHTMLAttribute(ms),
        },
        new_duration_string: formatMsToTimeString(new_duration),
        old_duration_string: formatMsToTimeString(old_duration),
        old_duration: parseInt(old_duration),
        new_duration: parseInt(new_duration),
      };

      const index = acc.findIndex((item) => item.id === curr.id);

      if (index === -1) {
        acc.push({
          id,
          episode_number,
          full_name,
          isNew: getIsEpisodeNewlyReleased(ms),
          changes: [changeItem],
        });
      } else {
        acc[index].changes.push(changeItem);
      }
      return acc;
    }, [])
    .filter((episode) =>
      episode.changes.some((change) => change.new_duration < change.old_duration)
    )
    .map((episode) => {
      const { changes } = episode;
      const oldestChange = changes[changes.length - 1];
      const newestChange = changes[0];
      const isOriginalLength =
        oldestChange.old_duration_string === newestChange.new_duration_string;

      const episodeWithoutUnusedProperties = {
        ...episode,
        changes: episode.changes.map(({ date, new_duration_string, old_duration_string }) => ({
          date,
          new_duration_string,
          old_duration_string,
        })),
      };

      return {
        ...episodeWithoutUnusedProperties,
        isOriginalLength,
      };
    });
};

const mapLastChecked = (rows) => {
  return parseInt(rows[0]?.miliseconds);
};

module.exports = {
  mapMissingEpisodes,
  mapShortenedEpisodes,
  mapLastChecked,
};


================================================
FILE: server/db/migration/missingEpisodesPerNow.js
================================================
const missingEpisodesPerNow = [
  "#1458 - Chris D’Elia",
  "#1255 - Alex Jones Returns",
  "#1218 - Gad Saad",
  "#1206 - Mike Ward & Pantelis",
  "#1197 - Michael Malice",
  "#1187 - Kyle Kulinski",
  "#1141 - Theo Von",
  "#1103 - Tom Segura",
  "#1093 - Owen Benjamin, Kurt Metzger",
  "#1036 - Ari Shaffir, Bert Kreischer & Tom Segura",
  "#1033 - Owen Benjamin",
  "#998 - Owen Benjamin",
  "#980 - Chris D’Elia",
  "#979 - Sargon of Akkad",
  "#963 - Michael Malice",
  "#925 - Theo Von",
  "#920 - Gavin McInnes",
  "#912 - Pete Holmes",
  "#911 - Alex Jones, Eddie Bravo",
  "#820 - Milo Yiannopoulos",
  "#810 - Big Jay Oakerson",
  "#750 - Kip Andersen, Keegan Kuhn, producers of Conspiracy",
  "#746 - TJ Kirk",
  "#742 - Aubrey Marcus",
  "#710 - Gavin McInnes",
  "#702 - Milo Yiannopoulos",
  "#674 - Brian Redban",
  "#664 - Tom Segura & Christina Pazsitzky",
  "#640 - Charles C. Johnson",
  "#594 - Russell Peters",
  "#582 - David Seaman",
  "#570 - Ryan Parsons",
  "#538 - Stefan Molyneux",
  "#537 - Rich Vos",
  "#533 - Chris D’elia",
  "#525 - Bert Kreischer",
  "#520 - David Seaman",
  "#512 - Dan Savage",
  "#506 - Moshe Kasher",
  "#488 - Iliza Shlesinger",
  "#487 - David Seaman",
  "#463 - Louis Theroux",
  "#461 - David Seaman",
  "#454 - War Machine",
  "#443 - Neal Brennan",
  "#441 - Brian Dunning",
  "#411 - Dave Asprey",
  "#375 - Shane Smith",
  "#374 - Marc Maron",
  "#368 - David Seaman",
  "#361 - Dave Asprey, Tait Fletcher",
  "#358 - Bert Kreischer",
  "#354 - Ari Shaffir, Amy Schumer",
  "#349 - Greg Fitzsimmons",
  "#344 - Stanley Krippner, Christopher Ryan",
  "#340 - JD Kelley",
  "#331 - Dr. Steven Greer",
  "#324 - Sam Sheridan",
  "#320 - Tim Ferriss",
  "#314 - Ian Edwards",
  "#303 - Matt Vengrin, Brian Redban",
  "#294 - Ari Shaffir",
  "#276 - David Seaman, Abby Martin, Dell Cameron, Brian Redban",
  "#275 - Dave Asprey",
  "#256 - David Seaman",
  "#246 - Maynard J Keenan (Part 1)",
  "#246 - Maynard J Keenan (Part 2)",
  "#239 - Adam Kokesh",
  "#232 - Giorgio Tsoukalos",
  "#227 - Ari Shaffir",
  "#213 - Eddie Bravo",
  "#198 - Brody Stevens",
  "#182 - Bryan Callen, Jimmy Burke, Brian Redban",
  "#176 - Steven Rinella",
  "#159 - Nick Thune",
  "#155 - Dave Attell",
  "#152 - Brian Redban",
  "#149 - LIVE FROM THE ICEHOUSE (PART ONE)",
  "#134 - Kevin Smith (Part 4)",
  "#132 - Bert Kreischer",
  "#128 - Joey Diaz, Brian Redban",
  "#122 - Jamie Kilstein",
  "#119 - Jan Irvin",
  "#118 - Ari Shaffir",
  "#116 - Russell Peters, Junior Simpson",
  "#114 - Neal Brennan",
  "#113 - Brian Posehn",
  "#112 - Cliffy B, Johnny Cristo",
  "#110 - Duncan Trussell",
  "#108 - Joey Diaz, Brian Redban",
  "#107 - Doug Benson",
  "#102 - John Heffron",
  "#98 - Daryl Wright, Brian Whitaker",
  "#97 - Freddy Lockhart, Brian Redban",
  "#92 - Jim Norton",
  "#91 - Bill Burr",
  "#88 - Andy Dick",
  "#82 - Dave Foley",
  "#81 - Pete Johansson",
  "#75 - Sam Tripoli",
  "#72 - Ari Shaffir",
  "#66 - Nick Swardson",
  "#57 - Jayson Thibault, Brian Redban",
  "#55 - Duncan Trussell",
  "#50 - Little Esther",
  "#48 - Brian Redban",
  "#42 - Duncan Trussell",
  "#40 - Tyler Knight",
  "#27 - Sam Tripoli",
  "#21 - Brian Redban",
  "#20 - Tom Segura",
  "#4 - Brian Redban",
  "Fight Companion - February 19, 2017",
];

module.exports = missingEpisodesPerNow;


================================================
FILE: server/db/migration/schema.sql
================================================
CREATE DATABASE jre_missing;

DROP TABLE IF EXISTS all_eps, test_table, date_removed, date_re_added, all_eps_log;

CREATE TABLE all_eps(
  id SERIAL NOT NULL UNIQUE PRIMARY KEY,
  episode_number INTEGER,
  full_name VARCHAR(255) NOT NULL, 
  on_spotify BOOLEAN NOT NULL,
  duration INTEGER
);

CREATE TABLE test_table(
  id SERIAL NOT NULL UNIQUE PRIMARY KEY,
  episode_number INTEGER,
  full_name VARCHAR(255) NOT NULL, 
  on_spotify BOOLEAN NOT NULL,
  duration INTEGER
);

CREATE TABLE date_removed(
  id INTEGER NOT NULL,
  date_removed timestamptz(0) NOT NULL,
  PRIMARY KEY (id, date_removed),
  CONSTRAINT fk_id
    FOREIGN KEY(id) 
	  REFERENCES all_eps(id)
);

CREATE TABLE date_re_added(
  id INTEGER NOT NULL,
  re_added timestamptz(0) NOT NULL,
  PRIMARY KEY (id, re_added),
  CONSTRAINT fk_id
    FOREIGN KEY(id)
	  REFERENCES all_eps(id)
);

CREATE TABLE all_eps_log(
  last_checked timestamptz(0),
  last_modified timestamptz(0)
);

CREATE TABLE duration_changes(
  id SERIAL NOT NULL UNIQUE PRIMARY KEY,
  episode_id INTEGER NOT NULL,
  date timestamptz(0) NOT NULL,
  old_duration INTEGER,
  new_duration INTEGER,
  CONSTRAINT fk_id
    FOREIGN KEY(episode_id) 
	  REFERENCES all_eps(id)
);

CREATE OR REPLACE FUNCTION change_duration() 
RETURNS TRIGGER AS $cl$ 
BEGIN 
  INSERT INTO duration_changes VALUES(DEFAULT, NEW.id, now(), OLD.duration, NEW.duration);
  RETURN NEW;
END; 
$cl$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION last_modified() 
RETURNS TRIGGER AS $lm$ 
BEGIN 
  UPDATE all_eps_log SET last_modified=now();
  RETURN NEW;
END; 
$lm$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION spotify_status() 
RETURNS TRIGGER AS $ss$ 
BEGIN 
  IF (new.on_spotify = true) THEN
    INSERT INTO date_re_added VALUES(NEW.id, now());
    RETURN NEW; 
    ELSEIF (new.on_spotify = false) THEN
  INSERT INTO date_removed VALUES(NEW.id, now());
    RETURN NEW; 
    END IF;
  RETURN NULL; 
END; 
$ss$ LANGUAGE plpgsql;

CREATE TRIGGER change_duration
AFTER UPDATE ON all_eps
FOR EACH ROW
WHEN (NEW.duration IS DISTINCT FROM OLD.duration)
EXECUTE PROCEDURE change_duration();

CREATE TRIGGER spotify_status
AFTER UPDATE ON all_eps
FOR EACH ROW
WHEN (OLD.on_spotify IS DISTINCT FROM NEW.on_spotify)
EXECUTE PROCEDURE spotify_status();

CREATE TRIGGER last_modified
AFTER UPDATE OR INSERT OR DELETE ON all_eps
FOR EACH ROW
EXECUTE PROCEDURE last_modified();

================================================
FILE: server/db/migration/seeds.sql
================================================
INSERT INTO all_eps_log VALUES(now(), NULL);

================================================
FILE: server/db/migration/setup.js
================================================
const missingEpisodesPerNow = require("./missingEpisodesPerNow");
const getSpotifyEpisodes = require("../../lib/getSpotifyEpisodes");
const getEpisodeNumber = require("../../lib/getEpisodeNumber");
const pool = require("../connect");

require("dotenv").config();

getSpotifyEpisodes().then(async (episodes) => {
  const allEpisodes = episodes.concat(missingEpisodesPerNow);
  allEpisodes.sort((a, b) => getEpisodeNumber(a.name) - getEpisodeNumber(b.name));

  await (async () => {
    for (const ep of allEpisodes) {
      const client = await pool.connect();
      const epNumber = getEpisodeNumber(ep.name);

      try {
        const onSpotify = !missingEpisodesPerNow.includes(ep.name);
        await client.query(`INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)`, [
          epNumber,
          ep.name,
          onSpotify,
          ep.duration,
        ]);
      } finally {
        client.release();
      }
    }
  })().catch((err) => console.error(err.message));

  console.info("inserts done");
});


================================================
FILE: server/lib/getEpisodeNumber.js
================================================
// Matches episodes starting with a number, either with or without preceding hashtag (#)
function getEpisodeNumber(name) {
  return parseInt(name?.match(/^(\d+)|(?<=^#)(\d+)/)) || null;
}

module.exports = getEpisodeNumber;


================================================
FILE: server/lib/getEpisodeNumber.test.js
================================================
const getEpisodeNumber = require("./getEpisodeNumber");

describe("Return number from regular episode title where the episode name starts with the number", () => {
  test("Return number when hashtag precedes number (i.e. #42)", () => {
    expect(getEpisodeNumber("#1789 - Tom Papa")).toBe(1789);
  });

  test("Return number when no hashtag precedes number", () => {
    expect(getEpisodeNumber("32 - Duncan Trussell")).toBe(32);
  });
});

describe("Do not return episode number when the episode name do not start with a number", () => {
  test("return null when episode number is not at the beginning of the title", () => {
    expect(getEpisodeNumber("JRE #1789 - Tom Papa")).toBeNull();
    expect(getEpisodeNumber("JRE 1789 - Tom Papa")).toBeNull();
    expect(getEpisodeNumber("Fight Companion #32 - Joey Diaz")).toBeNull();
  });

  test("return null when there is no episode number in the title", () => {
    expect(getEpisodeNumber("Fight Companion - February 19, 2017 (Part 2")).toBeNull();
  });
});

describe("Is type of number", () => {
  test("return a number and not a string when episode name start with an episode number", () => {
    expect(typeof getEpisodeNumber("#1789 - Tom Papa")).toBe("number");
  });
});


================================================
FILE: server/lib/getSpotifyEpisodes.js
================================================
/* eslint-disable no-undef */
const SpotifyWebApi = require("spotify-web-api-node");
require("dotenv").config();

const JRE_SHOW_ID = "4rOoJ6Egrf8K2IrywzwOMk";

async function getSpotifyEpisodes() {
  try {
    const spotifyApi = new SpotifyWebApi({
      clientId: process.env.SPOTIFY_CLIENT_ID,
      clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
    });

    const tokenData = await spotifyApi.clientCredentialsGrant();
    console.info(`The access token expires in ${tokenData.body["expires_in"]}`);
    spotifyApi.setAccessToken(tokenData.body["access_token"]);

    const spotifyEpisodes = [];
    const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {
      market: "US",
      limit: 50,
      offset: spotifyEpisodes.length,
    });

    console.info(`Started fetching episodes from Spotify at ${new Date().toString()}`);

    spotifyEpisodes.push(
      ...episodes.body.items.map((ep) => {
        return { name: ep.name, duration: ep.duration_ms };
      })
    );

    const totalEpisodes = episodes.body.total;

    while (spotifyEpisodes.length < totalEpisodes) {
      const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {
        market: "US",
        limit: 50,
        offset: spotifyEpisodes.length,
      });
      spotifyEpisodes.push(
        ...episodes.body.items.map((ep) => {
          return { name: ep.name, duration: ep.duration_ms };
        })
      );
    }
    console.log(
      `${spotifyEpisodes.length} out of ${totalEpisodes} JRE episodes on Spotify successfully fetched`
    );

    return spotifyEpisodes;
  } catch (err) {
    console.error(err.message);
  }
}

module.exports = getSpotifyEpisodes;


================================================
FILE: server/package.json
================================================
{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "dev": "cross-env NODE_ENV=development npm run dev-server",
    "dev:all": "cross-env NODE_ENV=development run-p dev-server worker",
    "dev-server": "nodemon app.js",
    "worker": "node worker/worker.js",
    "refresh-db": "node worker/tasks/refreshDb.js",
    "setup": "node db/migration/setup.js",
    "test": "jest",
    "test:coverage": "jest --coverage"
  },
  "author": "",
  "dependencies": {
    "date-fns": "^2.30.0",
    "date-fns-tz": "^2.0.0",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "express-rate-limit": "^7.1.3",
    "express-slow-down": "^2.0.0",
    "helmet": "^7.0.0",
    "node-schedule": "^2.1.1",
    "npm-run-all": "^4.1.5",
    "pg": "^8.11.3",
    "spotify-web-api-node": "^5.0.2"
  },
  "devDependencies": {
    "cors": "^2.8.5",
    "cross-env": "^7.0.3",
    "eslint": "^8.52.0",
    "eslint-config-prettier": "^9.0.0",
    "jest": "^29.7.0",
    "nodemon": "^3.0.1"
  }
}


================================================
FILE: server/utils/utils.js
================================================
const { zonedTimeToUtc, utcToZonedTime, format: formatTz } = require("date-fns-tz");

const getClientLocalTime = (date, pattern) => {
  const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const utcDate = zonedTimeToUtc(date, userTimezone);
  const zonedDate = utcToZonedTime(utcDate, userTimezone);
  const lastCheckedDate = formatTz(zonedDate, pattern, {
    timeZone: userTimezone,
  });
  return lastCheckedDate;
};

const getDateString = (time) => {
  return getClientLocalTime(time, "PPP");
};

const getDateTimeHTMLAttribute = (time) => {
  return getClientLocalTime(time, "yyyy-MM-dd");
};

const formatMsToTimeString = (time) => {
  const hours = Math.floor(time / 1000 / 60 / 60);
  const minutesRest = (time / 1000 / 60) % 60;
  const seconds = (time / 1000) % 60;
  return `${Math.floor(hours)} hr ${Math.floor(minutesRest)} min ${Math.floor(seconds)} sec`;
};

module.exports = {
  getClientLocalTime,
  formatMsToTimeString,
  getDateString,
  getDateTimeHTMLAttribute,
};


================================================
FILE: server/worker/tasks/refreshDb.js
================================================
const pg = require("pg");
const pool = new pg.Pool();

const DB = require("../../db/db");
const getEpisodeNumber = require("../../lib/getEpisodeNumber");
const getSpotifyEpisodes = require("../../lib/getSpotifyEpisodes");

async function refreshDb() {
  await (async () => {
    const client = await pool.connect();
    const db = DB(client);
    try {
      console.info("worker running");

      const spotifyEpisodes = await getSpotifyEpisodes();
      if (!spotifyEpisodes) {
        throw new Error("No spotify episodes got fetched!");
      }
      const spotifyEpisodeNames = spotifyEpisodes.map((ep) => ep.name);
      let allEpisodes = await db.getAllEpisodes();
      let someEpisodeNameGotUpdated = false;

      for (const dbEpisode of allEpisodes) {
        const correspondingSpotifyEpisode = spotifyEpisodes.find(
          (ep) => ep.name === dbEpisode.full_name
        );

        if (correspondingSpotifyEpisode && !dbEpisode.duration) {
          console.info(
            `Inserting missing duration for episode ${dbEpisode.full_name} (duration: ${correspondingSpotifyEpisode.duration}) `
          );
          await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);
        } else if (correspondingSpotifyEpisode) {
          if (correspondingSpotifyEpisode.duration !== dbEpisode.duration) {
            await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);
            console.info(
              ` \n\n Spotify has changed the duration of episode: ${dbEpisode.full_name} \n
                      from: ${dbEpisode.duration} \n 
                      to: ${correspondingSpotifyEpisode.duration} \n\n`
            );
          }
        }
      }

      for (const spotifyEpisode of spotifyEpisodes) {
        for (const dbEpisode of allEpisodes) {
          if (
            dbEpisode.episode_number &&
            didEpisodeChangeName(spotifyEpisode.name, dbEpisode)
          ) {
            await db.updateEpisodeName(spotifyEpisode.name, dbEpisode.id);
            someEpisodeNameGotUpdated = true;
            console.info(
              ` \n\n spotify updated the name of an episode! \n
                                  from: ${dbEpisode.full_name} \n 
                                  to: ${spotifyEpisode.name} \n\n`
            );
            break;
          }
        }
        const isNewRelease = !allEpisodes.some((ep) => ep.full_name === spotifyEpisode.name);
        if (isNewRelease) {
          console.info(`New episode released: ${spotifyEpisode.name}`);
          await db.insertNewEpisode(spotifyEpisode);
        }
      }

      if (someEpisodeNameGotUpdated) allEpisodes = await db.getAllEpisodes();

      for (const dbEpisode of allEpisodes) {
        if (!spotifyEpisodeNames.includes(dbEpisode.full_name)) {
          if (dbEpisode.on_spotify) {
            await db.setSpotifyStatus(dbEpisode, false);
            console.info(`\n\nNew episode removed!: ${dbEpisode.full_name} \n\n`);
          }
        } else if (!dbEpisode.on_spotify) {
          await db.setSpotifyStatus(dbEpisode, true);
          console.info(`\n\nNew episode re-added: ${dbEpisode.full_name} \n\n`);
        }
      }

      await db.setLastCheckedNow();
      console.info("Worker ran successfully");
    } finally {
      client.release();
    }
  })().catch((err) => console.warn(`Worker failed to run: ${err.message}`));
}

function didEpisodeChangeName(spotifyEpisodeName, dbEpisode) {
  const epNumber = getEpisodeNumber(spotifyEpisodeName);
  const { full_name, episode_number } = dbEpisode;
  return (
    full_name !== spotifyEpisodeName &&
    epNumber == episode_number &&
    !full_name.toLowerCase().includes("(part") &&
    !spotifyEpisodeName.toLowerCase().includes("(part")
  );
}

refreshDb();

module.exports = refreshDb;


================================================
FILE: server/worker/worker.js
================================================
const schedule = require("node-schedule");
const refreshDb = require("./tasks/refreshDb");
require("dotenv").config();
// eslint-disable-next-line no-undef
const { CRON_INTERVAL } = process.env;

schedule.scheduleJob(`*/${CRON_INTERVAL} * * * *`, refreshDb);
Download .txt
gitextract_5r8ltyem/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── codeql.yml
│       └── main.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── client/
│   ├── .eslintrc.json
│   ├── index.html
│   ├── package.json
│   ├── public/
│   │   ├── ads.txt
│   │   └── robots.txt
│   ├── src/
│   │   ├── components/
│   │   │   ├── AmountInfo.jsx
│   │   │   ├── AmountInfo.module.css
│   │   │   ├── App.css
│   │   │   ├── App.jsx
│   │   │   ├── ChangeDetails.jsx
│   │   │   ├── ChangeDetails.module.css
│   │   │   ├── Coffee.jsx
│   │   │   ├── Coffee.module.css
│   │   │   ├── Contact.jsx
│   │   │   ├── Contact.module.css
│   │   │   ├── Disclosure.jsx
│   │   │   ├── Disclosure.module.css
│   │   │   ├── Episode.jsx
│   │   │   ├── Episode.module.css
│   │   │   ├── EpisodeList.jsx
│   │   │   ├── EpisodeList.module.css
│   │   │   ├── Error.jsx
│   │   │   ├── Error.module.css
│   │   │   ├── Github.jsx
│   │   │   ├── Github.module.css
│   │   │   ├── Header.jsx
│   │   │   ├── Header.module.css
│   │   │   ├── ListTabs.jsx
│   │   │   ├── ListTabs.module.css
│   │   │   ├── ScrollButton.jsx
│   │   │   ├── ScrollButton.module.css
│   │   │   ├── Searchbox.jsx
│   │   │   ├── Searchbox.module.css
│   │   │   ├── Sort.jsx
│   │   │   ├── Sort.module.css
│   │   │   ├── Sponsor.jsx
│   │   │   ├── Sponsor.module.css
│   │   │   ├── Tag.jsx
│   │   │   └── Tag.module.css
│   │   ├── hooks/
│   │   │   ├── useFetch.js
│   │   │   ├── useMinLoadingTime.js
│   │   │   └── useScroll.js
│   │   ├── index.css
│   │   ├── index.jsx
│   │   ├── skeletons/
│   │   │   ├── SkeletonList.jsx
│   │   │   ├── SkeletonStyles.module.css
│   │   │   └── SkeletonText.jsx
│   │   ├── utils.js
│   │   └── utils.test.js
│   ├── tsconfig.json
│   ├── vite-env.d.ts
│   └── vite.config.ts
└── server/
    ├── .eslintrc.json
    ├── api/
    │   ├── __mocks__/
    │   │   └── mockResponse.js
    │   ├── dev-api.js
    │   └── index.js
    ├── app.js
    ├── db/
    │   ├── connect.js
    │   ├── db.js
    │   ├── mapQueries.js
    │   └── migration/
    │       ├── missingEpisodesPerNow.js
    │       ├── schema.sql
    │       ├── seeds.sql
    │       └── setup.js
    ├── lib/
    │   ├── getEpisodeNumber.js
    │   ├── getEpisodeNumber.test.js
    │   └── getSpotifyEpisodes.js
    ├── package.json
    ├── utils/
    │   └── utils.js
    └── worker/
        ├── tasks/
        │   └── refreshDb.js
        └── worker.js
Download .txt
SYMBOL INDEX (18 symbols across 8 files)

FILE: client/src/components/App.jsx
  function App (line 18) | function App() {

FILE: client/src/components/ScrollButton.jsx
  function handleClick (line 9) | function handleClick() {

FILE: client/src/components/Sort.jsx
  function Option (line 98) | function Option({ optionName, selected, setSelected, handleSort }) {

FILE: server/db/mapQueries.js
  constant DAYS_THRESHOLD_NEW (line 8) | const DAYS_THRESHOLD_NEW = 14;

FILE: server/db/migration/schema.sql
  type all_eps (line 5) | CREATE TABLE all_eps(
  type test_table (line 13) | CREATE TABLE test_table(
  type date_removed (line 21) | CREATE TABLE date_removed(
  type date_re_added (line 30) | CREATE TABLE date_re_added(
  type all_eps_log (line 39) | CREATE TABLE all_eps_log(
  type duration_changes (line 44) | CREATE TABLE duration_changes(
  function change_duration (line 55) | CREATE OR REPLACE FUNCTION change_duration()
  function last_modified (line 63) | CREATE OR REPLACE FUNCTION last_modified()
  function spotify_status (line 71) | CREATE OR REPLACE FUNCTION spotify_status()

FILE: server/lib/getEpisodeNumber.js
  function getEpisodeNumber (line 2) | function getEpisodeNumber(name) {

FILE: server/lib/getSpotifyEpisodes.js
  constant JRE_SHOW_ID (line 5) | const JRE_SHOW_ID = "4rOoJ6Egrf8K2IrywzwOMk";
  function getSpotifyEpisodes (line 7) | async function getSpotifyEpisodes() {

FILE: server/worker/tasks/refreshDb.js
  function refreshDb (line 8) | async function refreshDb() {
  function didEpisodeChangeName (line 90) | function didEpisodeChangeName(spotifyEpisodeName, dbEpisode) {
Condensed preview — 78 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (105K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 20,
    "preview": "github: [cotterjd]\r\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "chars": 3097,
    "preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 886,
    "preview": "name: Run Tests\n\non:\n  push:\n    branches:\n      - \"*\"\n  pull_request:\n    branches:\n      - \"*\"\n\njobs:\n  test:\n    runs"
  },
  {
    "path": ".gitignore",
    "chars": 440,
    "preview": ".vscode\n.DS_Store\n**/.DS_Store\n\n# client\n\n## dependencies\nclient/node_modules\nclient/.pnp\nclient/.pnp.js\n\n## testing\ncli"
  },
  {
    "path": ".prettierrc",
    "chars": 67,
    "preview": "{\n  \"printWidth\": 95,\n  \"tabWidth\": 2,\n  \"bracketSameLine\": true\n}\n"
  },
  {
    "path": "LICENSE",
    "chars": 1062,
    "preview": "MIT License\n\nCopyright (c) 2021 HenB13\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
  },
  {
    "path": "README.md",
    "chars": 3263,
    "preview": "<div align=\"center\" text-align=\"center\">\n\n# JRE MISSING <img height=\"23px\" width=\"23px\" padding=\"5px\" src=\"./client/publ"
  },
  {
    "path": "client/.eslintrc.json",
    "chars": 755,
    "preview": "{\n  \"env\": {\n    \"browser\": true,\n    \"es2021\": true,\n    \"jest\": true\n  },\n  \"extends\": [\n    \"eslint:recommended\",\n   "
  },
  {
    "path": "client/index.html",
    "chars": 761,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"favicon.png\" />\n    <s"
  },
  {
    "path": "client/package.json",
    "chars": 1314,
    "preview": "{\n  \"name\": \"client\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@types/node\": \"^20.8.9\",\n    \"@t"
  },
  {
    "path": "client/public/ads.txt",
    "chars": 58,
    "preview": "google.com, pub-2393608933506016, DIRECT, f08c47fec0942fa0"
  },
  {
    "path": "client/public/robots.txt",
    "chars": 67,
    "preview": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "client/src/components/AmountInfo.jsx",
    "chars": 2070,
    "preview": "import classnames from \"classnames\";\nimport styles from \"./AmountInfo.module.css\";\nimport { getClientLocalTime, formatMi"
  },
  {
    "path": "client/src/components/AmountInfo.module.css",
    "chars": 1096,
    "preview": ".AmountInfo {\n  font-size: 2rem;\n}\n\n.AmountInfoItem {\n  display: block;\n  white-space: nowrap;\n}\n\n.AmountInfoItem .count"
  },
  {
    "path": "client/src/components/App.css",
    "chars": 893,
    "preview": ".App {\n  display: flex;\n  position: relative;\n}\n\n.left {\n  position: fixed;\n  top: 26rem;\n  bottom: 19rem;\n  left: 35rem"
  },
  {
    "path": "client/src/components/App.jsx",
    "chars": 3584,
    "preview": "import \"./App.css\";\nimport { useState, useEffect } from \"react\";\nimport useFetch from \"../hooks/useFetch\";\nimport useMin"
  },
  {
    "path": "client/src/components/ChangeDetails.jsx",
    "chars": 2921,
    "preview": "import { useState } from \"react\";\nimport classnames from \"classnames\";\nimport Disclosure from \"./Disclosure\";\nimport sty"
  },
  {
    "path": "client/src/components/ChangeDetails.module.css",
    "chars": 1521,
    "preview": ".ChangeDetails {\n  display: flex;\n  flex-direction: column;\n  font-size: var(--font-size-small);\n  color: var(--color-gr"
  },
  {
    "path": "client/src/components/Coffee.jsx",
    "chars": 502,
    "preview": "import styles from \"./Coffee.module.css\";\n\nconst Coffee = () => {\n  return (\n    <>\n      <a\n        className={styles.C"
  },
  {
    "path": "client/src/components/Coffee.module.css",
    "chars": 409,
    "preview": ".Coffee {\n  position: fixed;\n  top: 3.65rem;\n  left: 36rem;\n  font-weight: 500;\n  font-size: 16px;\n  cursor: pointer;\n}\n"
  },
  {
    "path": "client/src/components/Contact.jsx",
    "chars": 362,
    "preview": "import styles from \"./Contact.module.css\";\n\nimport EmailIcon from \"../icons/email.svg\";\n\nconst Contact = () => {\n  retur"
  },
  {
    "path": "client/src/components/Contact.module.css",
    "chars": 596,
    "preview": ".contact {\n  position: fixed;\n  display: flex;\n  align-items: center;\n  font-size: var(--font-size-medium);\n  top: 4rem;"
  },
  {
    "path": "client/src/components/Disclosure.jsx",
    "chars": 434,
    "preview": "import styles from \"./Disclosure.module.css\";\nimport classnames from \"classnames\";\n\nconst Disclosure = ({ isOpen, onClic"
  },
  {
    "path": "client/src/components/Disclosure.module.css",
    "chars": 249,
    "preview": ".Disclosure {\n  display: flex;\n  align-items: center;\n  gap: 1px;\n  transition: all 0.25s;\n  cursor: pointer;\n  color: v"
  },
  {
    "path": "client/src/components/Episode.jsx",
    "chars": 1402,
    "preview": "import styles from \"./Episode.module.css\";\nimport Tag from \"./Tag\";\n\nconst Episode = ({ variant, name, number, date, isN"
  },
  {
    "path": "client/src/components/Episode.module.css",
    "chars": 757,
    "preview": ".epContent {\n  display: flex;\n  flex-direction: column;\n}\n\n.tagsWrapper {\n  display: flex;\n  gap: 9px;\n  margin-bottom: "
  },
  {
    "path": "client/src/components/EpisodeList.jsx",
    "chars": 3862,
    "preview": "import classnames from \"classnames\";\nimport styles from \"./EpisodeList.module.css\";\nimport Episode from \"./Episode\";\nimp"
  },
  {
    "path": "client/src/components/EpisodeList.module.css",
    "chars": 849,
    "preview": ".wrapper .EpisodeList {\n  position: unset;\n  font-size: 1.4rem;\n  list-style-type: none;\n  display: flex;\n  flex-directi"
  },
  {
    "path": "client/src/components/Error.jsx",
    "chars": 267,
    "preview": "import AlertIcon from \"../icons/alertIcon.svg\";\nimport styles from \"./Error.module.css\";\n\nconst Error = ({ error }) => {"
  },
  {
    "path": "client/src/components/Error.module.css",
    "chars": 157,
    "preview": ".error {\n  display: flex;\n  align-items: center;\n  font-size: var(--font-size-medium);\n}\n\n.icon {\n  margin-right: 1rem;\n"
  },
  {
    "path": "client/src/components/Github.jsx",
    "chars": 540,
    "preview": "import GitHubLogo from \"../icons/github-1.png\";\nimport styles from \"./Github.module.css\";\n\nconst Github = () => {\n  retu"
  },
  {
    "path": "client/src/components/Github.module.css",
    "chars": 694,
    "preview": ".Github {\n  position: fixed;\n  top: 4rem;\n  left: 5rem;\n  display: flex;\n  align-items: center;\n}\n\n.GithubLogo {\n  trans"
  },
  {
    "path": "client/src/components/Header.jsx",
    "chars": 791,
    "preview": "import styles from \"./Header.module.css\";\n\nconst Header = () => {\n  return (\n    <header className={styles.Header}>\n    "
  },
  {
    "path": "client/src/components/Header.module.css",
    "chars": 1506,
    "preview": ".Header {\n  margin-bottom: 10rem;\n}\n\nh1 {\n  font-size: 8rem;\n\n  font-family: \"Teko\", sans-serif;\n  font-style: italic;\n "
  },
  {
    "path": "client/src/components/ListTabs.jsx",
    "chars": 1315,
    "preview": "import styles from \"./ListTabs.module.css\";\nimport classnames from \"classnames\";\n\nconst ListTabs = ({\n  listShown,\n  set"
  },
  {
    "path": "client/src/components/ListTabs.module.css",
    "chars": 638,
    "preview": ".ListTab {\n  font-size: var(--font-size-small);\n  display: flex;\n  gap: 2rem;\n  margin-bottom: 4rem;\n}\n\n.option {\n  tran"
  },
  {
    "path": "client/src/components/ScrollButton.jsx",
    "chars": 1269,
    "preview": "import TextTransition, { presets } from \"react-text-transition\";\nimport classnames from \"classnames\";\nimport ArrowDown f"
  },
  {
    "path": "client/src/components/ScrollButton.module.css",
    "chars": 1767,
    "preview": ".ScrollButton {\n  background: transparent;\n  outline: none;\n  color: var(--color-white);\n  font-family: var(--font-famil"
  },
  {
    "path": "client/src/components/Searchbox.jsx",
    "chars": 1806,
    "preview": "import classnames from \"classnames\";\nimport { useState } from \"react\";\nimport styles from \"./Searchbox.module.css\";\nimpo"
  },
  {
    "path": "client/src/components/Searchbox.module.css",
    "chars": 1027,
    "preview": ".Searchbox {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border: 1px solid var(--color-g"
  },
  {
    "path": "client/src/components/Sort.jsx",
    "chars": 3657,
    "preview": "import { useEffect } from \"react\";\nimport Arrow from \"../icons/arrow.svg\";\nimport { useState } from \"react\";\nimport clas"
  },
  {
    "path": "client/src/components/Sort.module.css",
    "chars": 1744,
    "preview": ".sort {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n  color: var(--color-grey-secondary);\n  marg"
  },
  {
    "path": "client/src/components/Sponsor.jsx",
    "chars": 262,
    "preview": "import styles from \"./Sponsor.module.css\";\n\nconst Sponsor = () => {\n  return (\n    <iframe\n      src=\"https://github.com"
  },
  {
    "path": "client/src/components/Sponsor.module.css",
    "chars": 444,
    "preview": ".githubSponsorButton {\n  position: fixed;\n  top: 3.55rem;\n  left: 20rem;\n  height: 35px;\n  width: 116px;\n  border: 0;\n  "
  },
  {
    "path": "client/src/components/Tag.jsx",
    "chars": 725,
    "preview": "import classnames from \"classnames\";\nimport { Tooltip } from \"react-tooltip\";\nimport styles from \"./Tag.module.css\";\n\nco"
  },
  {
    "path": "client/src/components/Tag.module.css",
    "chars": 905,
    "preview": ".tag {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  font-size: 11px;\n  color: var(--color-black)"
  },
  {
    "path": "client/src/hooks/useFetch.js",
    "chars": 1135,
    "preview": "import { useState, useEffect } from \"react\";\n\nconst useFetch = (url) => {\n  const [data, setData] = useState(null);\n  co"
  },
  {
    "path": "client/src/hooks/useMinLoadingTime.js",
    "chars": 456,
    "preview": "import { useState, useEffect } from \"react\";\n\nconst useMinLoadingTime = (minTime) => {\n  const [minLoadingTimeElapsed, s"
  },
  {
    "path": "client/src/hooks/useScroll.js",
    "chars": 1287,
    "preview": "import { useState, useEffect } from \"react\";\nimport _ from \"lodash\";\n\nconst useScroll = ({\n  refreshOnChange: [missingEp"
  },
  {
    "path": "client/src/index.css",
    "chars": 1891,
    "preview": "@import url(\"https://fonts.googleapis.com/css2?family=Oswald:wght@200;300;400;500;600;700&display=swap\");\n@import url(\"h"
  },
  {
    "path": "client/src/index.jsx",
    "chars": 265,
    "preview": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport \"./index.css\";\nimport App from \"./components/"
  },
  {
    "path": "client/src/skeletons/SkeletonList.jsx",
    "chars": 945,
    "preview": "import { useRef } from \"react\";\nimport styles from \"./SkeletonStyles.module.css\";\n\nconst SkeletonList = () => {\n  const "
  },
  {
    "path": "client/src/skeletons/SkeletonStyles.module.css",
    "chars": 2029,
    "preview": ".SkeletonList {\n  position: unset;\n  font-size: 1.4rem;\n  list-style-type: none;\n  display: flex;\n  flex-direction: colu"
  },
  {
    "path": "client/src/skeletons/SkeletonText.jsx",
    "chars": 661,
    "preview": "import classnames from \"classnames\";\nimport styles from \"./SkeletonStyles.module.css\";\n\nconst SkeletonElement = ({ elSty"
  },
  {
    "path": "client/src/utils.js",
    "chars": 1354,
    "preview": "import { zonedTimeToUtc, utcToZonedTime, format as formatTz } from \"date-fns-tz\";\n\nexport const getClientLocalTime = (da"
  },
  {
    "path": "client/src/utils.test.js",
    "chars": 1847,
    "preview": "import { formatMinutesToTimeAmountString } from \"./utils\";\n\ndescribe(\"get correct timestring given a minute amount as in"
  },
  {
    "path": "client/tsconfig.json",
    "chars": 584,
    "preview": "{\r\n  \"compilerOptions\": {\r\n    \"target\": \"ESNext\",\r\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\r\n    \"allowJs\": true,\r"
  },
  {
    "path": "client/vite-env.d.ts",
    "chars": 88,
    "preview": "/// <reference types=\"vite/client\" /> /// <referencetypes=\"vite-plugin-svgr/client\" />\r\n"
  },
  {
    "path": "client/vite.config.ts",
    "chars": 661,
    "preview": "import { defineConfig } from \"vitest/config\";\r\nimport react from \"@vitejs/plugin-react-swc\";\r\nimport tsconfigPaths from "
  },
  {
    "path": "server/.eslintrc.json",
    "chars": 424,
    "preview": "{\n  \"env\": {\n    \"commonjs\": true,\n    \"node\": true,\n    \"es2021\": true,\n    \"jest\": true\n  },\n  \"extends\": [\"eslint:rec"
  },
  {
    "path": "server/api/__mocks__/mockResponse.js",
    "chars": 3816,
    "preview": "const missingEpisodes = [\n  {\n    full_name: \"#374 - Marc Maron\",\n    episode_number: 374,\n    isNew: false,\n    date: {"
  },
  {
    "path": "server/api/dev-api.js",
    "chars": 434,
    "preview": "const express = require(\"express\");\r\nconst router = express.Router();\r\nconst { mockResponse } = require(\"./__mocks__/moc"
  },
  {
    "path": "server/api/index.js",
    "chars": 1864,
    "preview": "const express = require(\"express\");\nconst router = express.Router();\nconst DB = require(\"../db/db\");\nconst pool = requir"
  },
  {
    "path": "server/app.js",
    "chars": 915,
    "preview": "const express = require(\"express\");\nconst app = express();\nconst api = require(\"./api/index.js\");\nconst devApi = require"
  },
  {
    "path": "server/db/connect.js",
    "chars": 179,
    "preview": "const pg = require(\"pg\");\npg.types.setTypeParser(1184, (str) => str);\npg.defaults.poolSize = 25;\n\nrequire(\"dotenv\").conf"
  },
  {
    "path": "server/db/db.js",
    "chars": 2626,
    "preview": "const { mapMissingEpisodes, mapShortenedEpisodes, mapLastChecked } = require(\"./mapQueries\");\n\nconst getEpisodeNumber = "
  },
  {
    "path": "server/db/mapQueries.js",
    "chars": 2741,
    "preview": "const { differenceInDays, parseISO } = require(\"date-fns\");\nconst {\n  formatMsToTimeString,\n  getDateString,\n  getDateTi"
  },
  {
    "path": "server/db/migration/missingEpisodesPerNow.js",
    "chars": 3336,
    "preview": "const missingEpisodesPerNow = [\n  \"#1458 - Chris D’Elia\",\n  \"#1255 - Alex Jones Returns\",\n  \"#1218 - Gad Saad\",\n  \"#1206"
  },
  {
    "path": "server/db/migration/schema.sql",
    "chars": 2369,
    "preview": "CREATE DATABASE jre_missing;\n\nDROP TABLE IF EXISTS all_eps, test_table, date_removed, date_re_added, all_eps_log;\n\nCREAT"
  },
  {
    "path": "server/db/migration/seeds.sql",
    "chars": 44,
    "preview": "INSERT INTO all_eps_log VALUES(now(), NULL);"
  },
  {
    "path": "server/db/migration/setup.js",
    "chars": 1016,
    "preview": "const missingEpisodesPerNow = require(\"./missingEpisodesPerNow\");\nconst getSpotifyEpisodes = require(\"../../lib/getSpoti"
  },
  {
    "path": "server/lib/getEpisodeNumber.js",
    "chars": 224,
    "preview": "// Matches episodes starting with a number, either with or without preceding hashtag (#)\nfunction getEpisodeNumber(name)"
  },
  {
    "path": "server/lib/getEpisodeNumber.test.js",
    "chars": 1231,
    "preview": "const getEpisodeNumber = require(\"./getEpisodeNumber\");\n\ndescribe(\"Return number from regular episode title where the ep"
  },
  {
    "path": "server/lib/getSpotifyEpisodes.js",
    "chars": 1665,
    "preview": "/* eslint-disable no-undef */\nconst SpotifyWebApi = require(\"spotify-web-api-node\");\nrequire(\"dotenv\").config();\n\nconst "
  },
  {
    "path": "server/package.json",
    "chars": 1050,
    "preview": "{\n  \"name\": \"server\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"app.js\",\n  \"scripts\": {\n    \"start\": \"node a"
  },
  {
    "path": "server/utils/utils.js",
    "chars": 1007,
    "preview": "const { zonedTimeToUtc, utcToZonedTime, format: formatTz } = require(\"date-fns-tz\");\n\nconst getClientLocalTime = (date, "
  },
  {
    "path": "server/worker/tasks/refreshDb.js",
    "chars": 3831,
    "preview": "const pg = require(\"pg\");\nconst pool = new pg.Pool();\n\nconst DB = require(\"../../db/db\");\nconst getEpisodeNumber = requi"
  },
  {
    "path": "server/worker/worker.js",
    "chars": 259,
    "preview": "const schedule = require(\"node-schedule\");\nconst refreshDb = require(\"./tasks/refreshDb\");\nrequire(\"dotenv\").config();\n/"
  }
]

About this extraction

This page contains the full source code of the henb13/jre-missing GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 78 files (92.8 KB), approximately 27.5k tokens, and a symbol index with 18 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!