main f99299f5b575 cached
89 files
809.4 KB
245.9k tokens
193 symbols
1 requests
Download .txt
Showing preview only (854K chars total). Download the full file or copy to clipboard to get everything.
Repository: marcokreeft87/formulaone-card
Branch: main
Commit: f99299f5b575
Files: 89
Total size: 809.4 KB

Directory structure:
gitextract_77tvhghf/

├── .devcontainer/
│   └── devcontainer.json
├── .eslintrc.js
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   └── workflows/
│       ├── pr.yml
│       └── push.yml
├── .gitignore
├── .nvmrc
├── LICENSE
├── README.md
├── formulaone-card.js.LICENSE.txt
├── hacs.json
├── jest.config.js
├── package.json
├── src/
│   ├── api/
│   │   ├── client-base.ts
│   │   ├── ergast-client.ts
│   │   ├── f1-models.ts
│   │   ├── f1sensor-client.ts
│   │   ├── image-client.ts
│   │   ├── restcountry-client.ts
│   │   ├── vc-weather-client.ts
│   │   └── weather-models.ts
│   ├── cards/
│   │   ├── base-card.ts
│   │   ├── constructor-standings.ts
│   │   ├── countdown.ts
│   │   ├── driver-standings.ts
│   │   ├── last-result.ts
│   │   ├── next-race.ts
│   │   ├── results.ts
│   │   └── schedule.ts
│   ├── consts.ts
│   ├── directives/
│   │   └── action-handler-directive.ts
│   ├── editor.ts
│   ├── fonts.ts
│   ├── index.ts
│   ├── lib/
│   │   ├── constants.ts
│   │   ├── format_date.ts
│   │   ├── format_date_time.ts
│   │   ├── format_time.ts
│   │   └── use_am_pm.ts
│   ├── styles.ts
│   ├── types/
│   │   ├── formulaone-card-types.ts
│   │   └── rest-country-types.ts
│   └── utils.ts
├── test/
│   ├── configuration.yaml
│   └── lovelace.yaml
├── tests/
│   ├── api/
│   │   ├── ergast-client.test.ts
│   │   ├── image-client.test.ts
│   │   ├── restcountry-client.test.ts
│   │   └── weather-client.test.ts
│   ├── cards/
│   │   ├── base-card.test.ts
│   │   ├── constructor-standings.test.ts
│   │   ├── countdown.test.ts
│   │   ├── driver-standings.test.ts
│   │   ├── last-result.test.ts
│   │   ├── next-race.test.ts
│   │   ├── results.test.ts
│   │   └── schedule.test.ts
│   ├── config.ts
│   ├── index.test.ts
│   ├── lib/
│   │   ├── formate_date.test.ts
│   │   ├── formate_date_time.test.ts
│   │   └── formate_time.test.ts
│   ├── testdata/
│   │   ├── constructorStandings.json
│   │   ├── countries.json
│   │   ├── driverStandings.json
│   │   ├── localStorageMock.ts
│   │   ├── qualifying.json
│   │   ├── results.json
│   │   ├── schedule.json
│   │   ├── seasons.json
│   │   └── sprint.json
│   ├── utils/
│   │   ├── calculateWindDirection.test.ts
│   │   ├── checkConfig.test.ts
│   │   ├── getCircuitName.test.ts
│   │   ├── getCountryFlagUrl.test.ts
│   │   ├── getDriverName.test.ts
│   │   ├── getRefreshTime.test.ts
│   │   ├── getTeamImageUrl.test.ts
│   │   ├── hasConfigOrEntitiesChanged.test.ts
│   │   ├── reduceArray.test.ts
│   │   ├── renderHeader.test.ts
│   │   ├── renderLastYearsResults.test.ts
│   │   ├── renderRaceInfo.test.ts
│   │   └── renderWeatherInfo.test.ts
│   └── utils.ts
├── tsconfig.json
└── webpack.config.js

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

================================================
FILE: .devcontainer/devcontainer.json
================================================
{
  "name": "formulaone-card Dev",
  "image": "marcokreeft/hass-custom-devcontainer",
  "postCreateCommand": "sudo -E container setup-dev && npm add && sudo hass -c /config -v",
  "containerEnv": {
    "DEVCONTAINER": "1"
  },
  "appPort": "8124:8123",
  "forwardPorts": [8123],
  "mounts": [
    "source=${localWorkspaceFolder},target=/config/www/workspace,type=bind",
    "source=${localWorkspaceFolder}/test,target=/config/test,type=bind",
    "source=${localWorkspaceFolder}/test/configuration.yaml,target=/config/configuration.yaml,type=bind"
  ],
  "runArgs": ["--env-file", "${localWorkspaceFolder}/test/.env"],
  "extensions": [
    "github.vscode-pull-request-github",
    "esbenp.prettier-vscode",
    "spmeesseman.vscode-taskexplorer"
  ],
  "settings": {
    "files.eol": "\n",
    "editor.tabSize": 2,
    "editor.formatOnPaste": false,
    "editor.formatOnSave": true,
    "editor.formatOnType": true,
    "[javascript]": {
      "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[typescript]": {
      "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "files.trimTrailingWhitespace": true
  }
}

// {
// 	"image": "thomasloven/hass-custom-devcontainer",
// 	"postCreateCommand": "container setup && npm add",
// 	"forwardPorts": [8123],
// 	"mounts": [
// 	  "source=${localWorkspaceFolder},target=/config/www/workspace,type=bind",
// 	  "source=${localWorkspaceFolder}/test,target=/config/test,type=bind",
// 	  "source=${localWorkspaceFolder}/test/configuration.yaml,target=/config/configuration.yaml,type=bind"
// 	],
// 	"runArgs": ["--env-file", "${localWorkspaceFolder}/test/.env"]
// }


================================================
FILE: .eslintrc.js
================================================
module.exports = {
    "env": {
        "browser": true,
        "commonjs": true,
        "es2021": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended"
    ],
    "overrides": [
    ],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaVersion": "latest"
    },
    "plugins": [
        "@typescript-eslint"
    ],
    "rules": {
    }
}


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
 - Version [e.g. 22]

**Smartphone (please complete the following information):**
 - Device: [e.g. iPhone6]
 - OS: [e.g. iOS8.1]
 - Browser [e.g. stock browser, safari]
 - Version [e.g. 22]

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Have you tried if it is already a feature? Have you checked the wiki?**
A lot of feature requests are closed because the functionality is already existing 

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/workflows/pr.yml
================================================
name: Tests

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the "master" branch
  pull_request:
    branches: [ "main" ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  hacs:
    permissions: write-all
    runs-on: "ubuntu-latest"
    name: Test and validate
    steps:
      - name: Check out the repository
        uses: actions/checkout@v3

      - name: Set Timezone
        uses: szenius/set-timezone@v1.0
        with:
          timezoneLinux: "Europe/Amsterdam"
          timezoneMacos: "Europe/Amsterdam"
          timezoneWindows: "Central European Time"

      - name: Install dependencies
        run: npm install

      - name: Build project
        run: npm run build


================================================
FILE: .github/workflows/push.yml
================================================
name: Tests

on:
  push:
    branches:
      - main
    paths:
      - '**'
      - '!README.md'
  workflow_dispatch:

jobs:
  hacs:
    runs-on: "ubuntu-latest"
    name: Test, Build, and Release
    steps:
      - name: Check out the repository
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Set Timezone
        uses: szenius/set-timezone@v1.0
        with:
          timezoneLinux: "Europe/Amsterdam"
          timezoneMacos: "Europe/Amsterdam"
          timezoneWindows: "Central European Time"

      - name: HACS validation
        uses: hacs/action@main
        with:
          category: "plugin"
          ignore: brands

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build project
        run: npm run build

      - name: Get version from package.json
        id: package_version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      - name: Create Git tag from package.json version
        run: |
          git config user.name "github-actions"
          git config user.email "github-actions@github.com"
          git tag v${{ steps.package_version.outputs.version }}
          git push origin v${{ steps.package_version.outputs.version }}

      - name: Get commit messages since last tag
        id: changelog
        run: |
          LAST_TAG=$(git describe --tags --abbrev=0)
          LOG=$(git log ${LAST_TAG}..HEAD    --pretty=format:"- %s")
          echo "log<<EOF" >> $GITHUB_ENV
          echo "$LOG" >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV

      - name: Get latest commit message
        id: get_commit
        run: echo "message=$(git log -1 --pretty=%B)" >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release
        id: create_release
        uses: softprops/action-gh-release@v2
        with:
          body: ${{ env.log }}
          tag_name: v${{ steps.package_version.outputs.version }}
          name: v${{ steps.package_version.outputs.version }}
          files: |
            formulaone-card.js
            formulaone-card.js.gz
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Print release ID
        run: echo "Release created with ID ${{ steps.create_release.outputs.id }}"


================================================
FILE: .gitignore
================================================
node_modules/*
coverage/*
formulaone-card.js
formulaone-card.js.gz


================================================
FILE: .nvmrc
================================================
v18


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

Copyright (c) 2022 Marco Kreeft

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
================================================
# FormulaOne Card

[![GH-release](https://img.shields.io/github/v/release/marcokreeft87/formulaone-card.svg?style=flat-square)](https://github.com/marcokreeft87/formulaone-card/releases)
[![GH-last-commit](https://img.shields.io/github/last-commit/marcokreeft87/formulaone-card.svg?style=flat-square)](https://github.com/marcokreeft87/formulaone-card/commits/master)
[![GH-code-size](https://img.shields.io/github/languages/code-size/marcokreeft87/formulaone-card.svg?color=red&style=flat-square)](https://github.com/marcokreeft87/formulaone-card)
[![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=flat-square)](https://github.com/hacs/default)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/marcokreeft87/formulaone-card/main.svg?style=flat-square)](https://codecov.io/gh/marcokreeft87/formulaone-card/)
[![CodeFactor](https://www.codefactor.io/repository/github/marcokreeft87/formulaone-card/badge?style=flat-square)](https://www.codefactor.io/repository/github/marcokreeft87/formulaone-card)

# This card just displays the data. Data related bugs will probably be closed immediately

Present the data of [Formula One](https://ergast.com/mrd/) in a pretty way

Watch a demo of the card by BeardedTinker!

[![Demo of BeardedTinker](https://img.youtube.com/vi/z7blY6D-Qmk/0.jpg)](https://www.youtube.com/watch?v=z7blY6D-Qmk)

## Installation

### HACS (recommended)

Make sure you have [HACS](https://hacs.xyz/) (Home Assistant Community Store) installed.
<br>
<sub>_HACS is a third party community store and is not included in Home Assistant out of the box._</sub>

Just click here to directly go to the repository in HACS and click "Download": [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=marcokreeft87&repository=formulaone-card&category=plugin)

Or:

- Open HACS
- Go to "Frontend" section
- Click button with "+" icon
- Search for "formulaone-card"
- Click "Download" button and install repository in HACS

In both situations:

- _If u are using YAML mode then add this to your_ [Lovelace resources](https://my.home-assistant.io/redirect/lovelace_resources/)

  ```yaml
  url: /hacsfiles/formulaone-card/formulaone-card.js
  type: module
  ```

- Refresh your browser

### Manual install

Manually download [formulaone-card.js](https://raw.githubusercontent.com/marcokreeft87/formulaone-card/master/formulaone-card.js) and add it
to your `<config>/www/` folder and add the following to the `configuration.yaml` file:

```yaml
lovelace:
  resources:
    - url: /local/formulaone-card.js
      type: module
```

The above configuration can be managed directly in the Configuration -> Lovelace Dashboards -> Resources panel when not using YAML mode,
or added by clicking the "Add to lovelace" button on the HACS dashboard after installing the plugin.

> [!TIP]
> If you don't want to use the data from the Jolpi API directy but want to use a Home Assistant integration instead. Use [F1 sensor](https://github.com/Nicxe/f1_sensor)

## Configuration

| Name              | Type          | Default                             | Description                                      |
| ----------------- | ------------- | ----------------------------------- | ------------------------------------------------ |
| type              | string        | **Required**                        | `custom:formulaonecard`                          |
| source            | string        | 'jolpi'    |  The source you want to use for the card (jolpi/f1sensor). You will have to set the entity |
| entity            | string        |            | Only required when using source: f1sensor. Set it to the entity that contains the data needed for the card. So for example when using driver_standings use entity: sensor.f1_driver_standings |
| card_type         | string        | **Required**                        | The type of card you want to display (driver_standings,constructor_standings,next_race,schedule,last_result,results,countdown)            |
| title             | string        |                                     | The header of the card ( hidden when null or empty)            |
| date_locale       | string        |                                     | Override the locale used for the date and time formatting. [Available options listed here](https://www.w3.org/International/O-charset-lang.html)|
| image_clickable   | boolean       | `false`                             | Click on image leads to wikipedia, or not   |
| show_carnumber    | boolean       | `false`                             | Show the number of the car   |
| show_raceinfo     | boolean       | `false`                             | Show the info of the race in the countdown and next_race card |
| hide_tracklayout  | boolean       | `false`                             | Hide the track layout image in the card |
| hide_racedatetimes | boolean       | `false`                        | Hide the race information (dates and times of the qualifications/race/sprint) in the card |
| f1_font           | boolean       | `false`                             | Use the official F1 font for headers |
| location_clickable| boolean       | `false`                             | Click on the location leads to wikipedia   |
| previous_race     | enum          |                           |   Hide/strikethrough or make the past races italic options are (hide, strikethrough or italic) |
| standings         | object        |                                     | Configuration for the driver standings card     |
| translations      | dictionary    |  _[translations](#translations)_          | Dictionary to override the default translation  |
| actions           | object        |  _[Actions](#actions)_                                    | The tap, double tap or hold actions set on the image of the countdown, last_result, results, qualifying_results and next-race cards |
| row_limit         | number        |                                     | Limit the schedule, results, last_result, driver_standings and constructor_standings to this amount of row |
| countdown_type    | string or array | 'race'                              | Set the event to countdown to (race,qualifying,practice1,practice2,practice3,sprint,sprint_qualifying) |
| show_weather      | boolean       | `false`                             | Show the _[weather forecast](#Forecast)_ of the upcoming race |
| next_race_delay   | number        |                                     | Delay (in hours) before the card switches to the next race |
| show_lastyears_result | boolean   | `false`                             | Show the winner of last year (next_race, countdown) |
| only_show_date    | boolean       | `false`                             | Show the date of the next race (next_race)          |
| tabs_order        | array         |'results', 'qualifying', 'sprint'    | Determine the order of the tabs (result)    |
| show_refresh      | boolean       |`false`                              | Show the refresh button (top right)    |
| next_race_display | enum          |`date`                               | Show the date, time or both for the next race (date,time,datetime)    |
| show_event_details | boolean      |`false`                              | Show the date of the next event (countdown) |
| countdown_format | string         | d h m s                             | Determine which parts of the countdown is displayed, d (days), h (hours), m (minutes) or s (seconds) |

### Actions

This card supports all the default HA actions, except from more-info and toggle. See [Lovelace Actions](https://www.home-assistant.io/lovelace/actions/)
for more detailed descriptions and examples.

| Name            | Type        | Default      | Description                                                                                |
| --------------- | ----------- | ------------ | ------------------------------------------------------------------------------------------ |
| action          | string      | **Required** | `call-service`, `url`, `navigate`, `fire-dom-event`, `none`         |
| service         | string      |              | Service to call when `action` is `call-service`                                            |
| service_data    | object      |              | Optional data to include when `action` is `call-service`                                   |
| url_path        | string      |              | URL to open when `action` is `url`                                                         |
| navigation_path | string      |              | Path to navigate to when `action` is `navigate`                                            |
| confirmation    | bool/object | `false`      | Enable confirmation dialog                                                                 |
| haptic          | string      | `none`       | Haptic feedback (`success`, `warning`, `failure`, `light`, `medium`, `heavy`, `selection`) |

Actions example:

```yaml
type: custom:formulaone-card
card_type: next_race
show_raceinfo: true
actions:
  tap_action:
    action: navigate
    navigation_path: /lovelace/overview

```

## Example configurations

### Next race

```yaml
type: custom:formulaone-card
card_type: next_race
title: Next Race
date_locale: nl
image_clickable: false
```

![image](https://user-images.githubusercontent.com/10223677/194120592-3df715bc-888d-460b-8743-ec1ab6017b96.png)

### Constructor standings

```yaml
type: custom:formulaone-card
card_type: constructor_standings
title: Constructor Standings
```

![image](https://user-images.githubusercontent.com/10223677/194120698-b981aac2-8678-4f35-afc9-ca6bb8514566.png)

```yaml
type: custom:formulaone-card
card_type: constructor_standings
title: Constructor Standings
standings:
  show_teamlogo: true
```

![image](https://user-images.githubusercontent.com/10223677/213992061-91ade5f2-68bb-4572-84a1-5d5cf38e0645.png)

### Driver standings

```yaml
type: custom:formulaone-card
card_type: driver_standings
title: Driver Standings

```

![image](https://user-images.githubusercontent.com/10223677/194120796-28532a9d-a62d-44bb-8cb8-403bfa434a8b.png)

This card can also show the flags and team names of the driver:

```yaml
type: custom:formulaone-card
card_type: driver_standings
title: Driver Standings
standings:
  show_flag: true
  show_team: true
  show_teamlogo: true
  
```

### Schedule

```yaml
type: custom:formulaone-card
card_type: schedule
title: Schedule
date_locale: nl

```

![image](https://user-images.githubusercontent.com/10223677/194120864-be0db0e9-dd0b-42aa-8829-d094c23ef0a5.png)

This card can also show the flags of the countries of the tracks:

```yaml
type: custom:formulaone-card
card_type: schedule
standings:
  show_flag: true

```

### Last results

```yaml
type: custom:formulaone-card
card_type: last_result
title: Last Result

```

![image](https://user-images.githubusercontent.com/10223677/194120925-5fc6c1a7-8b2a-4c58-b89c-d0316d70efe9.png)

### Results

```yaml
type: custom:formulaone-card
card_type: results
title: Results
```

![image](https://user-images.githubusercontent.com/10223677/216916869-4d2dc991-3429-45f8-b286-0b08d538031f.png)

This card can also show the flags and team names of the driver, alongside the logo of the teams:

```yaml
type: custom:formulaone-card
card_type: results
title: Results
standings:
  show_flag: true
  show_team: true
  show_teamlogo: true
  
```

### Countdown

```yaml
type: custom:formulaone-card
card_type: countdown
```

![image](https://user-images.githubusercontent.com/10223677/213435405-fdb2ff7c-3364-43d5-80b0-0f253d9b60c8.png)

```yaml
type: custom:formulaone-card
card_type: countdown
f1_font: true
```

![image](https://user-images.githubusercontent.com/10223677/215340692-898a03ef-2f66-46fd-92da-6e842d413500.png)

```yaml
type: custom:formulaone-card
card_type: countdown
f1_font: true
show_raceinfo: false
countdown_type:
  - practice1
  - practice2
  - practice3
  - qualifying
  - sprint
  - race
```

![Screenshot 2025-04-29 095323](https://github.com/user-attachments/assets/600c239d-a20a-4cb7-997d-99a103b237e8)

## Icons

The following icons can be altered.

| Card type(s)                        | Key           | Default value                       |
| ----------------------------------- | ------------- | ----------------------------------- |
| results                             | results       | mdi:trophy                          |
| results                             | qualifying    | mdi:timer-outline                   |
| results                             | sprint        | mdi:flag-checkered                  |


## Standings

The display options for the standings can be altered


| Name                     | Type          | Default                             | Description                                      |
| ------------------       | ------------- | ----------------------------------- | ------------------------------------------------ |
| show_team                | boolean       | `true`                              | Hide or show the team name                       |
| show_flag                | boolean       | `true`                              | Hide or show the country flag                    |
| show_teamlogo            | boolean       | `true`                              | Hide or show the team logo                       |
| hide_season_selector     | boolean       | `false`                              | Hide or show the season selector                     |

## Translations

The following texts can be translated or altered.

| Card type(s) | Key | Default value |
| ----------------------------------- | ------------- | ----------------------------------- |
| next_race, schedule | date | 'Date' |
| next_race, countdown | practice1 | 'Practice 1' |
| next_race, countdown | practice2 | 'Practice 2' |
| next_race, countdown | practice3 | 'Practice 3' |
| next_race, countdown | race | 'Race' |
| schedule | round | 'Race' |
| next_race, countdown | racename | 'Race name' |
| next_race, countdown | circuitname | 'Circuit name' |
| next_race, countdown, schedule | location' | 'Location' |
| next_race, countdown | city | 'City' |
| next_race, countdown | sprint | 'Sprint' |
| next_race, countdown | qualifying | 'Qualifying' |
| next_race, countdown | sprint_qualifying : 'Sprint Qualifying' |
| next_race, countdown, schedule | endofseason | 'Season is over. See you next year!' |
| constructor_standings | constructor | 'Constructor' |
| constructor_standings, driver_standings, last_result | points | 'Pts' |
| constructor_standings, driver_standings | wins | 'Wins' |
| driver_standings, results | team | 'Team' |
| driver_standings, last_result, results | driver | 'Driver' |
| last_result | grid | 'Grid' |
| last_result | status | 'Status' |
| schedule | time | 'Time' |
| results | raceheader | 'Race' |
| results | seasonheader | 'Season' |
| results, driver_standings, constructor_standings | selectseason | 'Select season' |
| results | selectrace | 'Select race' |
| results | noresults | 'Please select a race thats already been run' |
| countdown | days | 'd' |
| countdown | hours | 'h' |
| countdown | minutes | 'm' |
| countdown | seconds | 's' |
| countdown | until | 'Until' |
| constructor_standings, driver_standings | no_standings | 'No standings available yet' |

Example:

```yaml
type: custom:formulaone-card
card_type: next_race
title: Next Race
date_locale: nl
image_clickable: true
translations: 
  'date' : 'Date'  
  'practice1' : 'Practice 1'
  'practice2' : 'Practice 2'
  'practice3' : 'Practice 3'
  'race' : 'Race'
  'racename' : 'Race name'
  'circuitname' : 'Circuit name'
  'location' : 'Location'
  'racetime' : 'Race'
  'sprint' : 'Sprint'
  'qualifying' : 'Qualifying'
  'endofseason' : 'Season is over. See you next year!!'

```

## Result card status translation

You can translate the status of the result and last_result card. But only the status column.
It works the same way as the other translations.

The possible values for the status column are:

Here are all possible values for the status property with their default translation:

```yaml
'Finished' : 'Finished',
'+1 Lap' : '+1 Lap',
'Engine' : 'Engine',
'+2 Laps' : '+2 Laps',
'Accident' : 'Accident',
'Collision' : 'Collision',
'Gearbox' : 'Gearbox',
'Spun off' : 'Spun off',
'+3 Laps' : '+3 Laps',
'Suspension' : 'Suspension',
'+4 Laps' : '+4 Laps',
'Transmission' : 'Transmission',
'Electrical' : 'Electrical',
'Brakes' : 'Brakes',
'Withdrew' : 'Withdrew',
'+5 Laps' : '+5 Laps',
'Clutch' : 'Clutch',
'Lapped' : 'Lapped',
'Retired' : 'Retired',
'Not classified' : 'Not classified',
'Fuel system' : 'Fuel system',
'+6 Laps' : '+6 Laps',
'Disqualified' : 'Disqualified',
'Turbo' : 'Turbo',
'Hydraulics' : 'Hydraulics',
'Overheating' : 'Overheating',
'Ignition' : 'Ignition',
'Oil leak' : 'Oil leak',
'Throttle' : 'Throttle',
'Out of fuel' : 'Out of fuel'
```

## Weather forecast

For this feature to work you have to get an API key [here](https://www.visualcrossing.com/sign-up) or use [F1 sensor](https://github.com/Nicxe/f1_sensor)

```yaml
show_weather: true
weather_options:
  source: visualcrossing or f1sensor
  unit: metric
  api_key: [YOUR API KEY HERE]
  entity: [f1sensor entity]
```


================================================
FILE: formulaone-card.js.LICENSE.txt
================================================
/**
 * @license
 * Copyright 2017 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

/**
 * @license
 * Copyright 2019 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

/**
 * @license
 * Copyright 2020 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

/**
 * @license
 * Copyright 2021 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

/**
 * @license
 * Copyright 2022 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */


================================================
FILE: hacs.json
================================================
{
    "name": "Formula One Card",
    "filename": "formulaone-card.js",
    "render_readme": true,
    "content_in_root": true
  }
  

================================================
FILE: jest.config.js
================================================
module.exports = {
    transform: {
        '^.+\\.ts?$': ['ts-jest', { "compiler": "ttypescript" } ],
        '^.+\\.(js|jsx)$': [
            'babel-jest', {
                'presets': ['@babel/preset-env'],
                "plugins": [
                    ["@babel/plugin-transform-runtime"]
                ]
            }]
        },
    testEnvironment: 'jsdom',
    testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
    setupFiles: [ "<rootDir>/tests/config.ts" ],
    collectCoverageFrom: ["**/src/**/*.{js,jsx,ts}", "!**/node_modules/**", "!**/vendor/**","!**/src/styles.ts","!**/src/fonts.ts", "!**/src/editor.ts"],
    transformIgnorePatterns: ["node_modules\/(?!(lit|lit-element|lit-html|@lit)\/)"]
};

================================================
FILE: package.json
================================================
{
  "name": "formulaone-card",
  "version": "1.14.6",
  "description": "Frontend card for Home Assistant to display Formula One data",
  "main": "index.js",
  "scripts": {
    "lint": "eslint src/**/*.ts",
    "dev": "webpack -c webpack.config.js",
    "build": "yarn lint && webpack -c webpack.config.js",
    "test": "jest",
    "coverage": "jest --coverage",
    "workflow": "jest --coverage --json --outputFile=/home/runner/work/formulaone-card/formulaone-card/jest.results.json"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/marcokreeft87/formulaone-card.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/marcokreeft87/formulaone-card/issues"
  },
  "homepage": "https://github.com/marcokreeft87/formulaone-card#readme",
  "devDependencies": {
    "@types/jest": "^29.5.3",
    "@typescript-eslint/eslint-plugin": "^5.59.8",
    "@typescript-eslint/parser": "^5.62.0",
    "codecov": "^3.8.3",
    "eslint": "^8.52.0",
    "home-assistant-js-websocket": "^9.1.0",
    "lit": "^3.0.2",
    "lit-element": "^3.3.3",
    "minify-html-literals-loader": "^1.1.1",
    "typescript": "^4.9.5",
    "webpack": "^5.89.0",
    "webpack-cli": "^5.1.4"
  },
  "dependencies": {
    "@babel/plugin-transform-runtime": "^7.22.5",
    "@babel/preset-env": "^7.23.8",
    "@lit-labs/scoped-registry-mixin": "^1.0.1",
    "@marcokreeft/ha-editor-formbuilder": "^2024.9.1",
    "babel-jest": "^29.7.0",
    "compression-webpack-plugin": "^10.0.0",
    "custom-card-helpers": "^1.9.0",
    "isomorphic-fetch": "^3.0.0",
    "jest-environment-jsdom": "^29.6.2",
    "jest-fetch-mock": "^3.0.3",
    "jest-ts-auto-mock": "^2.1.0",
    "minify-html-literals-loader": "^1.1.1",
    "ts-auto-mock": "^3.6.4",
    "ts-jest": "^29.1.1",
    "ts-loader": "^9.5.1",
    "ttypescript": "^1.5.15",
    "yarn": "^1.22.21"
  }
}


================================================
FILE: src/api/client-base.ts
================================================
import { LocalStorageItem } from "../types/formulaone-card-types";
import { ConstructorStanding, DriverStanding, Race } from "./f1-models";

export interface IClient {
  GetConstructorStandings(): Promise<ConstructorStanding[]>;

  GetConstructorStandingsForSeason(season: number | undefined) : Promise<ConstructorStanding[]>;

  GetSchedule(season: number) : Promise<Race[]>;    
  
  GetLastResult() : Promise<Race>;
  
  GetDriverStandings() : Promise<DriverStanding[]>;

  GetDriverStandingsForSeason(season: number | undefined) : Promise<DriverStanding[]>;

  RefreshCache(): void;
}

export abstract class ClientBase {  
  abstract baseUrl: string;

  async GetData<T>(endpoint: string, cacheResult: boolean, hoursBeforeInvalid: number): Promise<T> {
    const localStorageData = localStorage.getItem(endpoint);

    if (localStorageData && cacheResult) {
      const item: LocalStorageItem = <LocalStorageItem>JSON.parse(localStorageData);

      const checkDate = new Date();
      checkDate.setHours(checkDate.getHours() - hoursBeforeInvalid);

      if (new Date(item.created) > checkDate) {
        return <T>JSON.parse(item.data);
      }
    }

    const response = await fetch(`${this.baseUrl}/${endpoint}`, {
      headers: {
        Accept: 'application/json',
      }
    });

    if (!response || !response.ok) {
      return Promise.reject(response);
    }

    const data = await response.json();

    const item: LocalStorageItem = {
      data: JSON.stringify(data),
      created: new Date()
    }

    if (cacheResult) {
      localStorage.setItem(endpoint, JSON.stringify(item));
    }

    return data;
  }
}

================================================
FILE: src/api/ergast-client.ts
================================================
import { getRefreshTime } from '../utils';
import { ClientBase, IClient } from './client-base';
import { ConstructorStanding, DriverStanding, Race, RaceTable, Root, Season } from './f1-models';

export default class ErgastClient extends ClientBase implements IClient {

    baseUrl = 'https://api.jolpi.ca/ergast/f1';

    async GetSchedule(season: number) : Promise<Race[]> {      
      const data = await this.GetData<Root>(`${season}.json`, true, 72);

      return data.MRData.RaceTable.Races;
    }

    async GetLastResult() : Promise<Race> {      
      const refreshCacheHours = getRefreshTime('current/last/results.json');
      const data = await this.GetData<Root>('current/last/results.json', true, refreshCacheHours);
      
      return data.MRData.RaceTable.Races[0];
    }

    async GetDriverStandings() : Promise<DriverStanding[]> {      
      const refreshCacheHours = getRefreshTime('current/driverStandings.json');
      const data = await this.GetData<Root>('current/driverStandings.json', true, refreshCacheHours);

      const standingsLists = data.MRData.StandingsTable.StandingsLists;
      return standingsLists && standingsLists.length > 0 ? standingsLists[0].DriverStandings : [];
    }

    async GetDriverStandingsForSeason(selectedSeason: number | undefined) {
      if (!selectedSeason || selectedSeason === 0) {
        return this.GetDriverStandings();
      }

      const refreshCacheHours = getRefreshTime(`${selectedSeason}/driverStandings.json`);
      const data = await this.GetData<Root>(`${selectedSeason}/driverStandings.json`, true, refreshCacheHours);

      const standingsLists = data.MRData.StandingsTable.StandingsLists;
      return standingsLists && standingsLists.length > 0 ? standingsLists[0].DriverStandings : [];
    }

    async GetConstructorStandings() : Promise<ConstructorStanding[]> {      
      const refreshCacheHours = getRefreshTime('current/constructorStandings.json');
      const data = await this.GetData<Root>('current/constructorStandings.json', true, refreshCacheHours);

      const standingsLists = data.MRData.StandingsTable.StandingsLists;
      return standingsLists && standingsLists.length > 0 ? standingsLists[0].ConstructorStandings : [];
    }

    async GetConstructorStandingsForSeason(selectedSeason: number | undefined) {
      if (!selectedSeason || selectedSeason === 0) {
        return this.GetConstructorStandings();
      }

      const refreshCacheHours = getRefreshTime(`${selectedSeason}/constructorStandings.json`);
      const data = await this.GetData<Root>(`${selectedSeason}/constructorStandings.json`, true, refreshCacheHours);

      const standingsLists = data.MRData.StandingsTable.StandingsLists;
      return standingsLists && standingsLists.length > 0 ? standingsLists[0].ConstructorStandings : [];
    }

    async GetSprintResults(season: number, round: number) : Promise<RaceTable> {
      const data = await this.GetData<Root>(`${season}/${round}/sprint.json`, false, 0);

      return data.MRData.RaceTable;
    }

    async GetQualifyingResults(season: number, round: number) : Promise<RaceTable> {
      const data = await this.GetData<Root>(`${season}/${round}/qualifying.json`, false, 0);

      return data.MRData.RaceTable;
    }
    
    async GetResults(season: number, round: number) : Promise<RaceTable> {      
      const data = await this.GetData<Root>(`${season}/${round}/results.json`, false, 0);

      return data.MRData.RaceTable;
    }

    async GetSeasons() : Promise<Season[]> {
      const data = await this.GetData<Root>('seasons.json?limit=200', true, 72);
      
      return data.MRData.SeasonTable.Seasons;
    }

    async GetSeasonRaces(season: number) : Promise<Race[]> {
      const data = await this.GetData<Root>(`${season}.json`, true, 72);

      return data.MRData.RaceTable.Races;
    }

    async GetLastYearsResults(circuitName: string) : Promise<Race> {
      
      // Get schedule of last year
      const lastYear = new Date().getFullYear() - 1;
      const data = await this.GetData<Root>(`${lastYear}.json`, true, 72);

      // Get the index of the circuit on the schedule of last year
      const raceRound = data.MRData.RaceTable.Races.findIndex((race: Race) => {
        return race.Circuit.circuitName === circuitName;
      }) + 1; 

      // Get the results of the last year race
      const results = await this.GetData<Root>(`${lastYear}/${raceRound}/results.json`, false, 0);

      return results.MRData.RaceTable.Races[0];
    }

    async RefreshCache() {
      await this.GetData<Root>('current.json', true, 0);
      await this.GetData<Root>('current/last/results.json', true, 0);
      await this.GetData<Root>('current/driverStandings.json', true, 0);
      await this.GetData<Root>('current/constructorStandings.json', true, 0);
    }
}

================================================
FILE: src/api/f1-models.ts
================================================
export interface Root {
    MRData: Mrdata
  }
  
  export interface Mrdata {
    xmlns: string
    series: string
    url: string
    limit: string
    offset: string
    total: string
    RaceTable?: RaceTable
    SeasonTable?: SeasonTable
    StandingsTable?: StandingsTable
  }

  export interface StandingsTable {
    season: string
    StandingsLists: StandingsList[]
  }
  
  export interface StandingsList {
    season: string
    round: string
    ConstructorStandings: ConstructorStanding[]
    DriverStandings: DriverStanding[]
  }
  
  export interface DriverStanding {
    position: string
    positionText: string
    points: string
    wins: string
    Driver: Driver
    Constructors: Constructor[]
  }
  
  export interface ConstructorStanding {
    position: string
    positionText: string
    points: string
    wins: string
    Constructor: Constructor
  }
  
  export interface RaceTable {
    season: string
    round?: string
    Races?: Race[]
  }
  
  export interface Race {
    season: string
    round: string
    url: string
    raceName: string
    Circuit: Circuit
    date: string
    time: string
    Results?: Result[]
    QualifyingResults?: QualifyingResult[]
    SprintResults?: Result[]
    FirstPractice: FirstPractice
    SecondPractice: SecondPractice
    ThirdPractice?: ThirdPractice
    Qualifying?: Qualifying
    Sprint?: Sprint
    SprintQualifying?: Qualifying
  }
  
  export interface Circuit {
    circuitId: string
    url: string
    circuitName: string
    Location: Location
  }
  
  export interface Location {
    lat: string
    long: string
    locality: string
    country: string
  }
  
  export interface Result {
    number: string
    position: string
    positionText: string
    points: string
    Driver: Driver
    Constructor: Constructor
    grid: string
    laps: string
    status: string
    Time?: Time
    FastestLap?: FastestLap | undefined
  }

  export interface QualifyingResult {
    number: string;
    position: string;
    Driver: Driver;
    Constructor: Constructor;
    Q1: string;
    Q2: string;
    Q3: string;
 }
  
  export interface Driver {
    driverId: string
    permanentNumber: string
    code: string
    url: string
    givenName: string
    familyName: string
    dateOfBirth: string
    nationality: string
  }
  
  export interface Constructor {
    constructorId: string
    url: string
    name: string
    nationality: string
  }
  
  export interface Time {
    millis: string
    time: string
  }
  
  export interface FastestLap {
    rank: string
    lap: string
    Time: Time2
    AverageSpeed: AverageSpeed
  }
  
  export interface Time2 {
    time: string
  }
  
  export interface AverageSpeed {
    units: string
    speed: string
  }
  export interface SeasonTable {
    Seasons: Season[]
  }
  
  export interface Season {
    season: string
    url: string
  }
  
  export interface FirstPractice {
    date: string
    time: string
  }
  
  export interface SecondPractice {
    date: string
    time: string
  }
  
  export interface ThirdPractice {
    date: string
    time: string
  }
  
  export interface Qualifying {
    date: string
    time: string
  }
  
  export interface Sprint {
    date: string
    time: string
  }
  

================================================
FILE: src/api/f1sensor-client.ts
================================================
import { HomeAssistant } from 'custom-card-helpers';
import { ClientBase, IClient } from './client-base';
import { ConstructorStanding, DriverStanding, Race, RaceTable, Season } from './f1-models';
import { IWeatherClient, WeatherData } from './weather-models';
import { WeatherOptions } from '../types/formulaone-card-types';

export default class F1SensorClient extends ClientBase implements IClient, IWeatherClient {
    baseUrl: string;
    hass: HomeAssistant;
    entity: string;

    constructor(hass: HomeAssistant, entity: string) {
        super();  
        
        this.hass = hass;
        this.entity = entity;
    } 

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    async getRaceWeatherData(options: WeatherOptions, race: Race) : Promise<WeatherData> {
        const attributes = this.hass.states[options.entity]?.attributes;
        if (!attributes) {
            throw new Error('Weather data not found for the specified entity.');
        }

        return attributes as WeatherData;
    }

    async GetConstructorStandings() : Promise<ConstructorStanding[]> {   
        return this.hass.states[this.entity]?.attributes?.constructor_standings ?? [];
    }

    async GetConstructorStandingsForSeason(season: number | undefined) : Promise<ConstructorStanding[]> {
        const data = this.hass.states[this.entity]?.attributes;
        if (season && season !== parseFloat(data?.season))
            throw new Error('This entity is only valid for the current season. Please use source: jolpi for other seasons.');

        return data?.constructor_standings ?? [];
    }
    
    async GetDriverStandings(): Promise<DriverStanding[]> {
        return this.hass.states[this.entity]?.attributes?.driver_standings ?? [];
    }

    async GetDriverStandingsForSeason(season: number | undefined): Promise<DriverStanding[]> {
        const data = this.hass.states[this.entity]?.attributes;
        if (season && season !== parseFloat(data?.season))
            throw new Error('This entity is only valid for the current season. Please use source: jolpi for other seasons.');
        return data?.driver_standings ?? [];
    }   
    
    async GetSchedule(season: number): Promise<Race[]> {
        const data = this.hass.states[this.entity]?.attributes;

        if (season !== parseFloat(data?.season)) 
            throw new Error('This entity is only valid for the current season. Please use source: jolpi for other seasons.');

        return data?.races ?? [];   
    }

    GetLastResult(): Promise<Race> {
        throw new Error('Method not implemented.');
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    GetSprintResults(season: number, round: number): Promise<RaceTable> {
        throw new Error('Method not implemented.');
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    GetQualifyingResults(season: number, round: number): Promise<RaceTable> {
        throw new Error('Method not implemented.');
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    GetResults(season: number, round: number): Promise<RaceTable> {
        throw new Error('Method not implemented.');
    }
    GetSeasons(): Promise<Season[]> {
        throw new Error('Method not implemented.');
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    GetSeasonRaces(season: number): Promise<Race[]> {
        throw new Error('Method not implemented.');
    }
    RefreshCache(): void {
        throw new Error('Method not implemented.');
    }
}

================================================
FILE: src/api/image-client.ts
================================================
import { ImageConstants } from "../lib/constants";
import { LocalStorageItem } from "../types/formulaone-card-types";
import { getCircuitName, getCircuitNameLegacy } from "../utils";
import { Race } from "./f1-models";

export default class ImageClient {

    // Get image by url with fetch and save to local storage for 24 hours base64 encoded
    GetImage(url: string): string {
        // Check local storage for image
        const localStorageData = localStorage.getItem(url);

        if (localStorageData) {
            const item: LocalStorageItem = <LocalStorageItem>JSON.parse(localStorageData);

            const checkDate = new Date();
            checkDate.setHours(checkDate.getHours() - (24 * 7 * 4));

            if (new Date(item.created) > checkDate) {
                return item.data;
            }
        }

        fetch(url)
            .then(response => response.blob())
            .then(imageBlob => {
                const reader = new FileReader();
                reader.readAsDataURL(imageBlob); 
                // istanbul ignore next
                reader.onloadend = function() {
                    const base64data = reader.result;

                    const item: LocalStorageItem = {
                        data: base64data as string,
                        created: new Date()
                    }
    
                    localStorage.setItem(url, JSON.stringify(item));
    
                    return item.data;
                }                
            });

        return url;
    }

    GetTeamLogoImage(teamName: string, selectedSeason: number): string {
        
        teamName = teamName.toLocaleLowerCase().replace('_', '-');
        if (selectedSeason < 2026) {
            const exceptions = [{ teamName: 'red-bull', corrected: 'red-bull-racing'}, { teamName: 'alfa', corrected: 'alfa-romeo'}, { teamName: 'haas', corrected: 'haas-f1-team'}, { teamName: 'sauber', corrected: 'kick-sauber'}];

            const exception = exceptions.filter(exception => exception.teamName == teamName);
            if(exception.length > 0)
            {
                teamName = exception[0].corrected;
            }

            return this.GetImage(`${ImageConstants.TeamLogoCDNLegacy}/2024/${teamName.toLowerCase()}-logo.png.transform/2col-retina/image.png`);
        }

        const exceptions = [{ teamName: 'red-bull', corrected: 'redbullracing'}, { teamName: 'rb', corrected: 'racingbulls'}, { teamName: 'haas', corrected: 'haasf1team'}, { teamName: 'aston-martin', corrected: 'astonmartin'}];

        const exception = exceptions.filter(exception => exception.teamName == teamName);
        if(exception.length > 0)
        {
            teamName = exception[0].corrected;
        }

        return this.GetImage(`${ImageConstants.TeamLogoCDN}2026/${teamName.toLowerCase()}/2026${teamName.toLowerCase()}logo.webp`);
    }

    GetTrackLayoutImage(race: Race): string {
        const circuitName = getCircuitNameLegacy(race.Circuit.Location);
        const year = parseInt(race.season);

        if (year < 2026) {
            return this.GetImage(`${ImageConstants.F1CDNLegacy}/${circuitName}_Circuit`);
        }

        return this.GetImage(`${ImageConstants.F1CDN}${getCircuitName(race).toLowerCase()}detailed.webp`);
    }
}

================================================
FILE: src/api/restcountry-client.ts
================================================
import { LocalStorageItem } from "../types/formulaone-card-types";
import { Country } from "../types/rest-country-types";
import { ClientBase } from "./client-base";

export default class RestCountryClient extends ClientBase {
    
    baseUrl = 'https://restcountries.com/v2';
    allEndpoint = 'all?fields=name,flag,flags,nativeName,demonym,population,altSpellings';

    async GetAll() : Promise<Country[]> {   
        return await this.GetData<Country[]>(this.allEndpoint, true, 730);
    }
    
    GetCountriesFromLocalStorage() : Country[] {
        const localStorageData = localStorage.getItem(this.allEndpoint);

        if(localStorageData) {
            const item: LocalStorageItem = <LocalStorageItem>JSON.parse(localStorageData);
            return <Country[]>JSON.parse(item.data);
        }
        return [];
    }
}

================================================
FILE: src/api/vc-weather-client.ts
================================================
import { WeatherOptions, WeatherUnit } from '../types/formulaone-card-types';
import { ClientBase } from './client-base';
import { Race } from './f1-models';
import { IWeatherClient, WeatherData, WeatherResponse } from './weather-models';

export default class VCWeatherClient
    extends ClientBase
    implements IWeatherClient
{
    private readonly apiKey: string;
    private readonly unitGroup: string = 'metric';
    baseUrl =
        'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline';

    constructor(apiKey: string, unitGroup?: string) {
        super();
        this.apiKey = apiKey;
        this.unitGroup = unitGroup ?? this.unitGroup;
    }

    async getRaceWeatherData(options: WeatherOptions, race: Race): Promise<WeatherData> {
        const endpoint = `${race.Circuit.Location.lat}, ${ race.Circuit.Location.long}/${race.date}T${race.time}`;
        const contentType = 'json';

        const url = `${endpoint}?unitGroup=${this.unitGroup}&key=${this.apiKey}&contentType=${contentType}`;

        const data = await this.GetData<WeatherResponse>(url, true, 1);   

        const day = data?.days[0]

        return { 
            race_temperature: day.temp,
            race_temperature_unit: options.unit === WeatherUnit.Metric ? 'celsius' : 'fahrenheit',
            race_humidity: day.humidity,
            race_humidity_unit: '%',
            race_cloud_cover: day.cloudcover,
            race_cloud_cover_unit: '%',
            race_precipitation: day.precip,
            race_precipitation_unit : 'mm',
            race_wind_speed : day.windspeed,
            race_wind_speed_unit : 'm/s',
            race_wind_direction: this.calculateWindDirection(day.winddir),  
            race_wind_from_direction_degrees: day.winddir,
            race_wind_from_direction_unit: 'degrees',
            race_feelslike: day.feelslike,
            race_feelslike_unit: options.unit === WeatherUnit.Metric ? 'celsius' : 'fahrenheit',
            race_precipitation_prob: day.precipprob,
            icon: '',
            friendly_name: 'visualcrossing', 
        };
    }

    private calculateWindDirection = (windDirection: number) => {
        const directions = [
            { label: 'N', range: [0, 11.25] },
            { label: 'NNE', range: [11.25, 33.75] },
            { label: 'NE', range: [33.75, 56.25] },
            { label: 'ENE', range: [56.25, 78.75] },
            { label: 'E', range: [78.75, 101.25] },
            { label: 'ESE', range: [101.25, 123.75] },
            { label: 'SE', range: [123.75, 146.25] },
            { label: 'SSE', range: [146.25, 168.75] },
            { label: 'S', range: [168.75, 191.25] },
            { label: 'SSW', range: [191.25, 213.75] },
            { label: 'SW', range: [213.75, 236.25] },
            { label: 'WSW', range: [236.25, 258.75] },
            { label: 'W', range: [258.75, 281.25] },
            { label: 'WNW', range: [281.25, 303.75] },
            { label: 'NW', range: [303.75, 326.25] },
            { label: 'NNW', range: [326.25, 348.75] },
            { label: 'N', range: [348.75, 360] },
        ];

        for (const { label, range } of directions) {
            if (windDirection >= range[0] && windDirection <= range[1]) {
                return label;
            }
        }
    };
}


================================================
FILE: src/api/weather-models.ts
================================================
import { WeatherOptions } from "../types/formulaone-card-types";
import { Race } from "./f1-models";

export interface IWeatherClient {
    getRaceWeatherData(options: WeatherOptions, race: Race) : Promise<WeatherData>; 
}

export interface WeatherData {
    race_temperature: number;
    race_temperature_unit: string;
    race_feelslike: number;
    race_feelslike_unit: string;
    race_humidity: number;
    race_humidity_unit: string;
    race_cloud_cover: number;
    race_cloud_cover_unit: string;
    race_precipitation: number;
    race_precipitation_prob: number;
    race_precipitation_unit: string;
    race_wind_speed: number;
    race_wind_speed_unit: string;
    race_wind_direction: string;
    race_wind_from_direction_degrees: number;
    race_wind_from_direction_unit: string;
    icon: string;
    friendly_name: string;
  }
  

export interface Hour {
    datetime: string;
    datetimeEpoch: number;
    temp: number;
    feelslike: number;
    humidity: number;
    dew: number;
    precip: number;
    precipprob: number;
    snow: number;
    snowdepth: number;
    preciptype: string[];
    windgust: number;
    windspeed: number;
    winddir: number;
    pressure: number;
    visibility: number;
    cloudcover: number;
    solarradiation: number;
    solarenergy?: number;
    uvindex: number;
    severerisk: number;
    conditions: string;
    icon: string;
    stations: string[];
    source: string;
    sunrise: string;
    sunriseEpoch?: number;
    sunset: string;
    sunsetEpoch?: number;
    moonphase?: number;
}

export interface Day {
    datetime: string;
    datetimeEpoch: number;
    tempmax: number;
    tempmin: number;
    temp: number;
    feelslikemax: number;
    feelslikemin: number;
    feelslike: number;
    dew: number;
    humidity: number;
    precip: number;
    precipprob: number;
    precipcover: number;
    preciptype: string[];
    snow: number;
    snowdepth: number;
    windgust: number;
    windspeed: number;
    winddir: number;
    pressure: number;
    cloudcover: number;
    visibility: number;
    solarradiation: number;
    solarenergy: number;
    uvindex: number;
    severerisk: number;
    sunrise: string;
    sunriseEpoch: number;
    sunset: string;
    sunsetEpoch: number;
    moonphase: number;
    conditions: string;
    description: string;
    icon: string;
    stations: string[];
    source: string;
    hours: Hour[];
}

export interface CurrentConditions {
    datetime: string;
    datetimeEpoch: number;
    temp: number;
    feelslike: number;
    humidity: number;
    dew: number;
    precip: number;
    precipprob: number;
    snow: number;
    snowdepth: number;
    //preciptype?: any;
    windgust: number;
    windspeed: number;
    winddir: number;
    pressure: number;
    visibility: number;
    cloudcover: number;
    solarradiation: number;
    //solarenergy?: any;
    uvindex: number;
    severerisk: number;
    conditions: string;
    icon: string;
    //stations: any[];
    source: string;
    sunrise: string;
    sunriseEpoch: number;
    sunset: string;
    sunsetEpoch: number;
    moonphase: number;
}

export interface WeatherResponse {
    queryCost: number;
    latitude: number;
    longitude: number;
    resolvedAddress: string;
    address: string;
    timezone: string;
    tzoffset: number;
    description: string;
    days: Day[];
    //alerts: any[];
    currentConditions: CurrentConditions;
}

================================================
FILE: src/cards/base-card.ts
================================================
import { HomeAssistant } from "custom-card-helpers";
import { HTMLTemplateResult } from "lit-html";
import FormulaOneCard from "..";
import JolpiClient from "../api/ergast-client";
import { Race } from "../api/f1-models";
import ImageClient from "../api/image-client";
import VCWeatherClient from "../api/vc-weather-client";
import { CardProperties, F1DataSource, FormulaOneCardConfig, Translation } from "../types/formulaone-card-types";
import { IClient } from "../api/client-base";
import F1SensorClient from "../api/f1sensor-client";
import { IWeatherClient } from "../api/weather-models";

export abstract class BaseCard {
    parent: FormulaOneCard;
    config: FormulaOneCardConfig;  
    client: IClient;
    resultsClient: JolpiClient;
    hass: HomeAssistant;
    weatherClient: IWeatherClient;
    imageClient: ImageClient;

    constructor(parent: FormulaOneCard) {     
        this.config = parent.config;           
        this.hass = parent._hass;
        this.client = this.config.source === F1DataSource.F1Sensor ? new F1SensorClient(this.hass, this.config.entity) : new JolpiClient();
        this.resultsClient = new JolpiClient();
        this.parent = parent;
        this.weatherClient = this.config.weather_options?.source ? new F1SensorClient(this.hass, this.config.entity) : new VCWeatherClient(this.config.weather_options?.api_key ?? '');
        this.imageClient = new ImageClient();
    }    

    translation(key: string) : string {

        if(!this.config.translations || Object.keys(this.config.translations).indexOf(key) < 0) {
            return this.defaultTranslations[key];
        }

        return this.config.translations[key];
    }

    abstract render() : HTMLTemplateResult;

    abstract cardSize() : number;

    abstract defaultTranslations: Translation;

    protected getProperties() {
        const cardProperties = this.parent.properties?.get('cardValues') as CardProperties;
        const races = cardProperties?.races as Race[];
        const selectedRace = cardProperties?.selectedRace as Race;
        const selectedSeason = cardProperties?.selectedSeason as number ?? new Date().getFullYear();
        const selectedTabIndex = cardProperties?.selectedTabIndex as number ?? 0;
        return { races, selectedRace, selectedSeason, selectedTabIndex };
    }

    protected getParentCardValues() {
        const cardValues = this.parent.properties ?? new Map<string, unknown>();
        const properties = cardValues.get('cardValues') as CardProperties ?? {} as CardProperties;
        properties.selectedSeason = properties.selectedSeason ?? new Date().getFullYear();
        return { properties, cardValues };
    }
}


================================================
FILE: src/cards/constructor-standings.ts
================================================
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from "..";
import { ConstructorStanding } from "../api/f1-models";
import { getApiErrorMessage, getApiLoadingMessage, getEndOfSeasonMessage, getTeamImage, reduceArray } from "../utils";
import { BaseCard } from "./base-card";

export default class ConstructorStandings extends BaseCard {    
    defaultTranslations = {
        'constructor' : 'Constructor',   
        'points' : 'Pts',
        'wins' : 'Wins',
        'no_standings' : 'No standings available yet',
        'selectseason' : 'Select season'
    };

    constructor(parent: FormulaOneCard) {
        super(parent);
    }    
    
    cardSize(): number {        
        return 11;
    }

    renderStandingRow(standing: ConstructorStanding, selectedSeason: number): HTMLTemplateResult {
        return html`
            <tr>
                <td class="width-40 text-center">${standing.position}</td>
                <td>
                    ${(this.config.standings?.show_teamlogo ? html`<img class="constructor-logo" height="20" width="20" src="${getTeamImage(this, standing.Constructor.constructorId, selectedSeason)}">&nbsp;` : '')}
                    ${standing.Constructor.name}
                </td>
                <td class="width-60 text-center">${standing.points}</td>
                <td class="text-center">${standing.wins}</td>
            </tr>`;
    }

    render() : HTMLTemplateResult {
        const { selectedSeason } = this.getProperties ? this.getProperties() : { selectedSeason: new Date().getFullYear() };

        const selectedSeasonChanged = (ev: any): void => {
            this.setSeason(ev);
        };

        return html`
            ${this.config.standings?.hide_season_selector ? '' : html`<table>
                <tr>
                    <td>
                        Season<br />
                        ${until(
                            this.resultsClient.GetSeasons().then((response: any[]) => {
                                const seasons = response.reverse();
                                return html`<select name="selectedSeason" @change=${selectedSeasonChanged}>
                                    <option value="0">${this.translation('selectseason')}</option>
                                    ${seasons.map(season => html`<option value="${season.season}" ?selected=${selectedSeason === parseInt(season.season)}>${season.season}</option>`)}
                                </select>`;
                            }).catch(() => html`${getApiErrorMessage('seasons')}`),
                            html`${getApiLoadingMessage()}`
                        )}
                    </td>
                </tr>
            </table>`}
            ${until(
                this.resultsClient.GetConstructorStandingsForSeason(selectedSeason).then((response: ConstructorStanding[]) =>
                    response.length === 0 ?
                        html`${getEndOfSeasonMessage(this.translation('no_standings'))}` :
                        html`
                            <table>
                                <thead>
                                <tr>
                                    <th class="width-50">&nbsp;</th>
                                    <th>${this.translation('constructor')}</th>
                                    <th class="width-60 text-center">${this.translation('points')}</th>
                                    <th class="text-center">${this.translation('wins')}</th>
                                </tr>
                                </thead>
                                <tbody>
                                    ${reduceArray(response, this.config.row_limit).map(standing => this.renderStandingRow(standing, selectedSeason))}
                                </tbody>
                            </table>
                        `
                ).catch(() => html`${getApiErrorMessage('standings')}`),
                html`${getApiLoadingMessage()}`
            )}
        `;
    }

    setSeason(ev: any) {
        const season = ev.target.value;
        const { properties, cardValues } = this.getParentCardValues();
        properties.selectedSeason = season;
        cardValues.set('cardValues', properties);
        this.parent.properties = cardValues;
    }  
}


================================================
FILE: src/cards/countdown.ts
================================================
import { ActionHandlerEvent, formatDateTime, hasAction, HomeAssistant } from "custom-card-helpers";
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import { asyncReplace } from 'lit/directives/async-replace.js';
import FormulaOneCard from "..";
import { Race } from "../api/f1-models";
import { actionHandler } from "../directives/action-handler-directive";
import { CountdownType } from "../types/formulaone-card-types";
import { clickHandler, getApiErrorMessage, getApiLoadingMessage, getCountryFlagByName, getEndOfSeasonMessage, renderHeader, renderRaceInfo } from "../utils";
import { BaseCard } from "./base-card";

export default class Countdown extends BaseCard {
    hass: HomeAssistant;
    defaultTranslations = {
        'days' : 'd',   
        'hours' : 'h',
        'minutes' : 'm',
        'seconds' : 's',
        'endofseason' : 'Season is over. See you next year!',
        'racenow' : 'We are racing!',
        'date' : 'Date',   
        'practice1' : 'Practice 1',
        'practice2' : 'Practice 2',
        'practice3' : 'Practice 3',
        'race' : 'Race',
        'round' : 'Round',
        'racename' : 'Race name',
        'circuitname' : 'Circuit name',
        'location' : 'Location',
        'city': 'City',
        'racetime' : 'Race',
        'sprint' : 'Sprint',
        'qualifying' : 'Qualifying',
        'sprint_qualifying' : 'Sprint Qualifying',
        'until' : 'Until'
    };

    constructor(parent: FormulaOneCard) {
        super(parent);
        
        this.config.countdown_type = this.config.countdown_type ?? CountdownType.Race;
    }
    
    cardSize(): number {
        return this.config.show_raceinfo ? 12 : 6;
    }

    renderHeader(race: Race): HTMLTemplateResult {        
        return this.config.show_raceinfo ? 
            html`<table><tr><td colspan="5">${renderHeader(this, race)}</td></tr>
            ${renderRaceInfo(this, race)}</table>`
            : null;
    }

    async *countDownTillDate(raceDateTime: Date) {

        while (raceDateTime > new Date()) {

            const now = new Date().getTime();
            const distance = raceDateTime.getTime() - now;

            const days = Math.floor(distance / (1000 * 60 * 60 * 24));
            const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
            const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
            const seconds = Math.floor((distance % (1000 * 60)) / 1000);

            const countdown_format = this.config.countdown_format ?? 'd h m s';

            let displayValue = '';
            if (countdown_format.includes('d')) {   
                displayValue += `${days}${this.translation('days')} `;
            }
            if (countdown_format.includes('h')) {
                displayValue += `${hours}${this.translation('hours')} `;
            }
            if (countdown_format.includes('m')) {
                displayValue += `${minutes}${this.translation('minutes')} `;
            }
            if (countdown_format.includes('s')) {
                displayValue += `${seconds}${this.translation('seconds')} `;
            }

            yield displayValue;

            /* istanbul ignore next */
            await new Promise((r) => setTimeout(r, 1000));
        }

        yield this.translation('racenow');
    }

    render() : HTMLTemplateResult {

        const _handleAction = (ev: ActionHandlerEvent): void => {
            if (this.hass && this.config.actions && ev.detail.action) {
                clickHandler(this.parent, this.config, this.hass, ev);
            }
        };

        return html`${until(
            this.client.GetSchedule(new Date().getFullYear()).then(response => {

                const { nextRace, raceDateTime, countdownType } = this.getNextEvent(response);

                if(!nextRace) {
                    return getEndOfSeasonMessage(this.translation('endofseason'));
                }

                const timer = this.countDownTillDate(raceDateTime);                
                const hasConfigAction = this.config.actions !== undefined;
                
                return html`<table @action=${_handleAction}
                                .actionHandler=${actionHandler({
                                    hasHold: hasAction(this.config.actions?.hold_action),
                                    hasDoubleClick: hasAction(this.config.actions?.double_tap_action),
                                })} class="${(hasConfigAction ? 'clickable' : null)}">
                                <tr>
                                    <td>
                                        <h2 class="${(this.config.f1_font ? 'formulaone-font' : '')}"><img height="25" src="${getCountryFlagByName(this, nextRace.Circuit.Location.country)}">&nbsp;&nbsp;  ${nextRace.round} :  ${nextRace.raceName}</h2>
                                    </td>
                                </tr>
                                <tr>
                                    <td class="text-center">
                                        <h1 class="${(this.config.f1_font ? 'formulaone-font' : '')}">${asyncReplace(timer)}</h1>
                                    </td>
                                </tr>
                                ${(
                                    Array.isArray(this.config.countdown_type) && this.config.countdown_type.length > 1 ?
                                        html`<tr>
                                                <td class="text-center">
                                                    <h1 class="${(this.config.f1_font ? 'formulaone-font' : '')}">${this.translation('until')} ${this.translation(countdownType.toLowerCase())}</h1>
                                                    <h3 class="${(this.config.f1_font ? 'formulaone-font' : '')}">${this.config.show_event_details ? formatDateTime(raceDateTime, this.hass.locale) : ''}</h3>
                                                </td>
                                            </tr>`
                                        : null
                                )}
                            </table>
                            ${this.renderHeader(nextRace)}`; 

            }).catch(() => html`${getApiErrorMessage('next race')}`),
            html`${getApiLoadingMessage()}`
        )}`;
    }

    getNextEvent(response: Race[]) {

        const nextRace = response.filter(race => {
            const raceDateTime = new Date(race.date + 'T' + race.time);
            raceDateTime.setHours(raceDateTime.getHours() + 3);
            return raceDateTime >= new Date();
        })[0];

        let raceDateTime = null;
        let countdownType = this.config.countdown_type as CountdownType;
        if(nextRace) {
            const countdownTypes = this.config.countdown_type as CountdownType[];

            const raceEvents = [
                { Date: nextRace.FirstPractice ? new Date(nextRace.FirstPractice.date + 'T' + nextRace.FirstPractice.time) : null, Type: CountdownType.Practice1 },
                { Date: nextRace.SecondPractice ? new Date(nextRace.SecondPractice.date + 'T' + nextRace.SecondPractice.time) : null, Type: CountdownType.Practice2 },
                { Date: nextRace.ThirdPractice ? new Date(nextRace.ThirdPractice.date + 'T' + nextRace.ThirdPractice.time) : null, Type: CountdownType.Practice3 },
                { Date: nextRace.Sprint ? new Date(nextRace.Sprint.date + 'T' + nextRace.Sprint.time) : null, Type: CountdownType.Sprint },
                { Date: nextRace.SprintQualifying ? new Date(nextRace.SprintQualifying.date + 'T' + nextRace.SprintQualifying.time) : null, Type: CountdownType.SprintQualifying },
                { Date: nextRace.Qualifying ? new Date(nextRace.Qualifying.date + 'T' + nextRace.Qualifying.time) : null, Type: CountdownType.Qualifying },
                { Date: new Date(nextRace.date + 'T' + nextRace.time), Type: CountdownType.Race }
            ].filter(x => x.Date).filter(x => x.Date > new Date()).sort((a, b) => a.Date.getTime() - b.Date.getTime());

            // Get the first countdown type that occurs in race events and get the date and time for that event
            const nextEvent = raceEvents.filter(x => countdownTypes?.includes(x.Type))[0];

            raceDateTime = nextEvent?.Date;
            countdownType = nextEvent?.Type ?? countdownType;
        }

        return { nextRace, raceDateTime, countdownType };
    }
}


================================================
FILE: src/cards/driver-standings.ts
================================================
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from "..";
import { DriverStanding } from "../api/f1-models";
import { getApiErrorMessage, getApiLoadingMessage, getCountryFlagByNationality, getDriverName, getEndOfSeasonMessage, reduceArray, renderConstructorColumn } from "../utils";
import { BaseCard } from "./base-card";

export default class DriverStandings extends BaseCard {
    defaultTranslations = {
        'driver' : 'Driver',   
        'team' : 'Team',
        'points' : 'Pts',
        'wins' : 'Wins',
        'no_standings' : 'No standings available yet',
        'selectseason' : 'Select season'
    };

    constructor(parent: FormulaOneCard) {
        super(parent);    
    }
    
    cardSize(): number {
        return 12;
    }  
    
    renderStandingRow(standing: DriverStanding, selectedSeason: number): HTMLTemplateResult {
        return html`
            <tr>
                <td class="width-40 text-center">${standing.position}</td>
                <td>${(this.config.standings?.show_flag ? html`<img height="10" width="20" src="${getCountryFlagByNationality(this, standing.Driver.nationality)}">&nbsp;` : '')}${standing.Driver.code}</td>
                <td>${getDriverName(standing.Driver, this.config)}</td>
                ${(this.config.standings?.show_team ? html`${renderConstructorColumn(this, standing.Constructors[standing.Constructors.length - 1], selectedSeason)}` : '')}
                <td class="width-60 text-center">${standing.points}</td>
                <td class="text-center">${standing.wins}</td>
            </tr>`;
    }

    render() : HTMLTemplateResult {
        const { selectedSeason } = this.getProperties ? this.getProperties() : { selectedSeason: new Date().getFullYear() };

        const selectedSeasonChanged = (ev: any): void => {
            this.setSeason(ev);
        };

        return html`
            ${this.config.standings?.hide_season_selector ? '' : html`<table>
                <tr>
                    <td>
                        Season<br />
                        ${until(
                            this.resultsClient.GetSeasons().then((response: any[]) => {
                                const seasons = response.reverse();
                                return this.config.standings?.hide_season_selector ? '' : html`<select name="selectedSeason" @change=${selectedSeasonChanged}>
                                    <option value="0">${this.translation('selectseason')}</option>
                                    ${seasons.map(season => html`<option value="${season.season}" ?selected=${selectedSeason === parseInt(season.season)}>${season.season}</option>`)}
                                </select>`;
                            }).catch(() => html`${getApiErrorMessage('seasons')}`),
                            html`${getApiLoadingMessage()}`
                        )}
                    </td>
                </tr>
            </table>`}
            ${until(
                this.resultsClient.GetDriverStandingsForSeason(selectedSeason).then((response: DriverStanding[]) =>
                    response.length === 0 ?
                        html`${getEndOfSeasonMessage(this.translation('no_standings'))}` :
                        html`
                            <table>
                                <thead>
                                <tr>
                                    <th class="width-50" colspan="2">&nbsp;</th>
                                    <th>${this.translation('driver')}</th>
                                    ${(this.config.standings?.show_team ? html`<th>${this.translation('team')}</th>` : '')}
                                    <th class="width-60 text-center">${this.translation('points')}</th>
                                    <th class="text-center">${this.translation('wins')}</th>
                                </tr>
                                </thead>
                                <tbody>
                                    ${reduceArray(response, this.config.row_limit).map(standing => this.renderStandingRow(standing, selectedSeason))}
                                </tbody>
                            </table>
                        `
                ).catch(() => html`${getApiErrorMessage('standings')}`),
                html`${getApiLoadingMessage()}`
            )}
        `;
    }

    setSeason(ev: any) {
        const season = ev.target.value;
        const { properties, cardValues } = this.getParentCardValues();
        properties.selectedSeason = season;
        cardValues.set('cardValues', properties);
        this.parent.properties = cardValues;
    }    
}


================================================
FILE: src/cards/last-result.ts
================================================
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from "..";
import { Result } from "../api/f1-models";
import { getApiErrorMessage, getApiLoadingMessage, getDriverName, getEndOfSeasonMessage, reduceArray, renderHeader, translateStatus } from "../utils";
import { BaseCard } from "./base-card";

export default class LastResult extends BaseCard {
    defaultTranslations = {
        'driver' : 'Driver',   
        'grid' : 'Grid',
        'points' : 'Points',
        'status' : 'Status',
        'no_result' : 'No result available yet',
    };

    constructor(parent: FormulaOneCard) {
        super(parent);    
    }  
    
    cardSize(): number {
        return 11;
    }

    renderResultRow(result: Result): HTMLTemplateResult {

        return html`
            <tr>
                <td class="width-50 text-center">${result.position}</td>
                <td>${getDriverName(result.Driver, this.config)}</td>
                <td>${result.grid}</td>
                <td class="width-60 text-center">${result.points}</td>
                <td class="width-50 text-center">${translateStatus(result.status, this.config)}</td>
            </tr>`;
    }   

    render() : HTMLTemplateResult {

        return html`${until(
            this.client.GetLastResult().then(response => 
                !response ?
                html`${getEndOfSeasonMessage(this.translation('no_result'))}` : 
                html` 
                    <table>
                        <tr>
                            <td>${renderHeader(this, response)}</td>
                        </tr>
                    </table>
                    <table>
                        <thead>                    
                            <tr>
                                <th>&nbsp;</th>
                                <th>${this.translation('driver')}</th>
                                <th class="text-center">${this.translation('grid')}</th>
                                <th class="text-center">${this.translation('points')}</th>
                                <th>${this.translation('status')}</th>
                            </tr>
                        </thead>
                        <tbody>
                            ${reduceArray(response.Results, this.config.row_limit).map(result => this.renderResultRow(result))}
                        </tbody>
                    </table>`)
                    .catch(() => html`${getApiErrorMessage('last result')}`),
            html`${getApiLoadingMessage()}`,
          )}`;
    }
}


================================================
FILE: src/cards/next-race.ts
================================================
import { HomeAssistant } from "custom-card-helpers";
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import { getApiErrorMessage, getApiLoadingMessage, getEndOfSeasonMessage, renderHeader, renderRaceInfo } from "../utils";
import { BaseCard } from "./base-card";
import { formatDateNumeric } from "../lib/format_date";
import { NextRaceDisplay } from "../types/formulaone-card-types";
import { Race } from "../api/f1-models";

export default class NextRace extends BaseCard {
    hass: HomeAssistant;
    defaultTranslations = {
        'date' : 'Date',   
        'practice1' : 'Practice 1',
        'practice2' : 'Practice 2',
        'practice3' : 'Practice 3',
        'race' : 'Race',
        'round' : 'Round',
        'racename' : 'Race name',
        'circuitname' : 'Circuit name',
        'location' : 'Location',
        'city': 'City',
        'racetime' : 'Race',
        'sprint' : 'Sprint',
        'qualifying' : 'Qualifying',        
        'sprint_qualifying' : 'Sprint Qualifying',
        'endofseason' : 'Season is over. See you next year!',
    };
    
    cardSize(): number {
        return 8;
    }
    
    render() : HTMLTemplateResult {
        return html`${until(
            this.client.GetSchedule(new Date().getFullYear()).then(response => {
                
                const delay = this.config.next_race_delay || 0;
                const nextRace = response.filter(race =>  {
                    const nextRaceDate = new Date(race.date + 'T' + race.time);

                    // Add the delay to the hours of the next race
                    nextRaceDate.setHours(nextRaceDate.getHours() + delay);

                    return nextRaceDate >= new Date();
                })[0];

                if(!nextRace) {
                    return getEndOfSeasonMessage(this.translation('endofseason'));
                }                
                
                return html`<table>
                        <tbody>
                            <tr>
                                <td colspan="5">${renderHeader(this, nextRace)}</td>
                            </tr>
                            ${this.config.show_raceinfo ? 
                                renderRaceInfo(this, nextRace) : 
                                this.config.only_show_date ? 
                                    html`<tr>
                                        <td class="text-center">
                                            <h1 class="${(this.config.f1_font ? 'formulaone-font' : '')}">${this.renderDateTime(nextRace)}</h1>
                                        </td>
                                    </tr>` : null
                                }  
                        </tbody>
                    </table>`
                }).catch(() => { 
                    return html`${getApiErrorMessage('next race')}`;
                }),
            html`${getApiLoadingMessage()}`
        )}`;
    }

    private renderDateTime(nextRace: Race) {
        switch(this.config.next_race_display) {
            case NextRaceDisplay.DateOnly:
                return formatDateNumeric(new Date(nextRace.date + 'T' + nextRace.time), this.hass.locale, this.config.date_locale);
            case NextRaceDisplay.TimeOnly:
                return new Date(nextRace.date + 'T' + nextRace.time).toLocaleTimeString(this.hass.locale.language, { hour: '2-digit', minute: '2-digit' });
            case NextRaceDisplay.DateAndTime:
                return formatDateNumeric(new Date(nextRace.date + 'T' + nextRace.time), this.hass.locale, this.config.date_locale) + ' ' + new Date(nextRace.date + 'T' + nextRace.time).toLocaleTimeString(this.hass.locale.language, { hour: '2-digit', minute: '2-digit' });
            default:
                return formatDateNumeric(new Date(nextRace.date + 'T' + nextRace.time), this.hass.locale, this.config.date_locale);
        }

        return null;
    }
}


================================================
FILE: src/cards/results.ts
================================================
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from "..";
import { QualifyingResult, Race, Result, Season } from "../api/f1-models";
import { CustomIcons, F1DataSource, FormulaOneCardTab, mwcTabBarEvent, SelectChangeEvent } from "../types/formulaone-card-types";
import { getApiErrorMessage, getApiLoadingMessage, getCountryFlagByNationality, getDriverName, reduceArray, renderConstructorColumn, renderHeader, translateStatus } from "../utils";
import { BaseCard } from "./base-card";

export default class Results extends BaseCard {    
    
    defaultTranslations = {
        'driver' : 'Driver',   
        'grid' : 'Grid',
        'team' : 'Team',
        'points' : 'Points',
        'status' : 'Status',
        'raceheader' : 'Race',
        'seasonheader' : 'Season',
        'selectseason' : 'Select season',
        'selectrace' : 'Select race',
        'noresults' : 'Please select a race thats already been run.',
        'q1' : 'Q1',
        'q2' : 'Q2',
        'q3' : 'Q3',        
        'finished' : 'Finished',
        'retired' : 'Retired',
        'disqualified' : 'Disqualified',
        'notclassified' : 'Not classified'
    };

    icons: CustomIcons = {
        'sprint' : 'mdi:flag-checkered',
        'qualifying' : 'mdi:timer-outline',
        'results' : 'mdi:trophy',
    }

    constructor(parent: FormulaOneCard) {
        super(parent);    

        if (this.config.source === F1DataSource.F1Sensor)
            throw new Error('F1Sensor is not supported for this card type. Please use source: jolpi.');
    } 
    
    cardSize(): number {
        return 12;
    }

    renderTabs(selectedRace: Race) : FormulaOneCardTab[] {
        const tabs: FormulaOneCardTab[] = [{
            title: 'Results',
            icon: this.icon('results'),
            content: this.renderResults(selectedRace),
            order: this.tabOrder('results')
        }, {
            title: 'Qualifying',
            icon: this.icon('qualifying'),
            content: this.renderQualifying(selectedRace),
            order: this.tabOrder('qualifying')
        }, {
            title: 'Sprint',
            icon: this.icon('sprint'),
            content: this.renderSprint(selectedRace),
            hide: !selectedRace?.SprintResults,
            order: this.tabOrder('sprint')
        }];

        return tabs.sort((a, b) => a.order - b.order);
    }

    renderSprint(selectedRace: Race) : HTMLTemplateResult {
        return selectedRace?.SprintResults ? 
            html`<table class="nopadding">
                    <thead>                    
                        <tr>
                            <th>&nbsp;</th>
                            <th>${this.translation('driver')}</th>
                            ${(this.config.standings?.show_team ? html`<th>${this.translation('team')}</th>` : '')}
                            <th class="text-center">${this.translation('grid')}</th>
                            <th class="text-center">${this.translation('points')}</th>
                            <th class="text-center">${this.translation('status')}</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${reduceArray(selectedRace.SprintResults, this.config.row_limit).map(result => this.renderResultRow(result, false, selectedRace.season))}
                    </tbody>
                </table>`
            : null;
    }

    renderQualifying(selectedRace: Race): HTMLTemplateResult {
        return selectedRace?.QualifyingResults ?
            html`<table class="nopadding">
                    <thead>                   
                        <tr>
                            <th>&nbsp;</th>
                            <th>${this.translation('driver')}</th>
                            ${(this.config.standings?.show_team ? html`<th>${this.translation('team')}</th>` : '')}
                            <th class="text-center">${this.translation('q1')}</th>
                            <th class="text-center">${this.translation('q2')}</th>
                            <th class="text-center">${this.translation('q3')}</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${reduceArray(selectedRace.QualifyingResults, this.config.row_limit).map(result => this.renderQualifyingResultRow(result, selectedRace.season))}
                    </tbody>
                </table>`            
            : null;
    }

    renderResults(selectedRace: Race): HTMLTemplateResult {
        const fastest = selectedRace?.Results?.filter((result) => result.FastestLap?.rank === '1')[0];
        return selectedRace?.Results ?
            html`<table class="nopadding">
                    <thead>                    
                        <tr>
                            <th>&nbsp;</th>
                            <th>${this.translation('driver')}</th>
                            ${(this.config.standings?.show_team ? html`<th>${this.translation('team')}</th>` : '')}
                            <th class="text-center">${this.translation('grid')}</th>
                            <th class="text-center">${this.translation('points')}</th>
                            <th>${this.translation('status')}</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${reduceArray(selectedRace.Results, this.config.row_limit).map(result => this.renderResultRow(result, result.position === fastest?.position, selectedRace.season))}
                    </tbody>
                    ${fastest ? html`
                    <tfoot>
                        <tr>
                            <td colspan="6" class="text-right"><small>* Fastest lap: ${fastest.FastestLap.Time.time}</small></td>
                        </tr>
                    </tfoot>` : ''}
                </table>` 
        : null;
    }   

    renderResultRow(result: Result, fastest: boolean, selectedSeason: string): HTMLTemplateResult {
        return html`
            <tr>
                <td class="width-50 text-center">${result.position}</td>
                <td>${(this.config.standings?.show_flag ? html`<img height="10" width="20" src="${getCountryFlagByNationality(this, result.Driver.nationality)}">&nbsp;` : '')}${getDriverName(result.Driver, this.config)}${fastest ? ' *' : ''}</td>
                ${(this.config.standings?.show_team ? html`${renderConstructorColumn(this, result.Constructor, parseInt(selectedSeason))}` : '')}
                <td>${result.grid}</td>
                <td class="width-60 text-center">${result.points}</td>
                <td class="width-50 text-center">${translateStatus(result.status, this.config)}</td>
            </tr>`;
    }

    renderQualifyingResultRow(result: QualifyingResult, selectedSeason: string): HTMLTemplateResult {
        return html`
            <tr>
                <td class="width-50 text-center">${result.position}</td>
                <td>${(this.config.standings?.show_flag ? html`<img height="10" width="20" src="${getCountryFlagByNationality(this, result.Driver.nationality)}">&nbsp;` : '')}${getDriverName(result.Driver, this.config)}</td>
                ${(this.config.standings?.show_team ? html`${renderConstructorColumn(this, result.Constructor, parseInt(selectedSeason))}` : '')}
                <td>${result.Q1}</td>
                <td>${result.Q2}</td>
                <td>${result.Q3}</td>
            </tr>`;
    }

    renderHeader(race?: Race): HTMLTemplateResult {
        
        if(race === null || race === undefined || parseInt(race.season) < 2018) {
            return null;
        }

        return renderHeader(this, race);
    }

    render() : HTMLTemplateResult {
        const { races, selectedRace, selectedSeason, selectedTabIndex } = this.getProperties();

        if(selectedSeason === new Date().getFullYear() && !selectedRace) {
            this.getLastResult();
        }
       
        const selectedSeasonChanged = (ev: SelectChangeEvent): void => {
            this.setRaces(ev);
        }

        const selectedRaceChanged = (ev: SelectChangeEvent): void => {
            this.setSelectedRace(ev);
        }

        const tabs = this.renderTabs(selectedRace);

        return html`
        <table>
            <tr>
                <td> 
                    ${this.translation('seasonheader')}<br />                      
                    ${until(
                        this.resultsClient.GetSeasons().then((response: Season[]) => { 
                            const seasons = response.reverse();
                                return html`<select name="selectedSeason" @change="${selectedSeasonChanged}">
                                        <option value="0">${this.translation('selectseason')}</option>
                                        ${seasons.map(season => {
                                            return html`<option value="${season.season}" ?selected=${selectedSeason === parseInt(season.season)}>${season.season}</option>`;
                                        })}
                                    </select>`;
                               
                            }).catch(() => {
                                return html`${getApiErrorMessage('seasons')}`;
                            }),
                            html`${getApiLoadingMessage()}`,
                        )}                 
                </td>
                <td>
                    ${this.translation('raceheader')}<br />
                    <select name="selectedRace" @change="${selectedRaceChanged}">
                        <option value="0" ?selected=${selectedRace === undefined}>${this.translation('selectrace')}</option>
                        ${races?.map(race => {
                            return html`<option value="${race.round}" ?selected=${selectedRace?.round == race.round}>${race.raceName}</option>`;
                        })}
                    </select>
                </td>
            </tr>
        </table>
        ${this.renderTabsHtml(tabs, selectedTabIndex, selectedRace)}`;       
        
    }

    renderTabsHtml = (tabs: FormulaOneCardTab[], selectedTabIndex: number, selectedRace?: Race): HTMLTemplateResult => {
        return selectedRace 
            ? html`<table>
                        <tr><td colspan="2">${this.renderHeader(selectedRace)}</td></tr>
                        ${tabs.filter(tab => tab.content).length > 0 ?
                            html`<tr class="transparent">
                                <td colspan="2">
                                    <mwc-tab-bar
                                    @MDCTabBar:activated=${(ev: mwcTabBarEvent) => 
                                        (this.setSelectedTabIndex(ev.detail.index))}
                                >
                                ${tabs.filter(tab => !tab.hide).map(
                                    (tab) =>  html`
                                            <mwc-tab
                                            ?hasImageIcon=${tab.icon}
                                            ><ha-icon
                                                    slot="icon"
                                                    icon="${tab.icon}"
                                                ></ha-icon>
                                            </mwc-tab>
                                        `,
                                    )}                    
                                </mwc-tab-bar>
                                <section>
                                    <article>
                                    ${tabs.filter(tab => !tab.hide).find((_, index) => index == selectedTabIndex).content}
                                    </article>
                                </section>
                                </td>
                            </tr>` : html`<tr><td colspan="2">${this.translation('noresults')}</td></tr>`}                    
                    </table>` 
                : html``;
    }

    setSelectedRace(ev: SelectChangeEvent) {
        const round = parseInt(ev.target.value);
        const { properties, cardValues } = this.getParentCardValues();

        properties.selectedRound = round;

        const selectedSeason = properties.selectedSeason as number;

        Promise.all([this.resultsClient.GetResults(selectedSeason, round), 
            this.resultsClient.GetQualifyingResults(selectedSeason, round),
            this.resultsClient.GetSprintResults(selectedSeason, round),
            this.resultsClient.GetSchedule(selectedSeason)])
            .then(([results, qualifyingResults, sprintResults, schedule]) => {

                let race = results.Races[0];
                /* istanbul ignore next */
                if(race) {
                    race.QualifyingResults = qualifyingResults.Races[0].QualifyingResults;
                    /* istanbul ignore next */
                    race.SprintResults = sprintResults?.Races[0]?.SprintResults
                    properties.selectedSeason = race.season;                    
                /* istanbul ignore next */
                } else {
                    /* istanbul ignore next */
                    race = schedule.filter((item: Race) => parseInt(item.round) == round)[0];
                }

                properties.selectedRace = race;
                cardValues.set('cardValues', properties);
                this.parent.properties = cardValues;
            });
    }

    private setRaces(ev: SelectChangeEvent) {
        const selectedSeason = ev.target.value;
        const { properties, cardValues } = this.getParentCardValues();

        this.resultsClient.GetSeasonRaces(parseInt(selectedSeason)).then((response: Race[]) => {            

            properties.selectedSeason = selectedSeason;
            properties.selectedRace = undefined;
            properties.races = response;
            cardValues.set('cardValues', properties);
            this.parent.properties = cardValues;
        });
    }

    private getUpcomingRace(now: Date, races: Race[]) : Race {
        
        const nextRaces = races.filter(race =>  {

            const raceDateTime = new Date(race.date + 'T' + race.time);
            const qualifyingDateTime = new Date(race.Qualifying.date + 'T' + race.Qualifying.time);
            const sprintDateTime = race.Sprint ? new Date(race.Sprint.date + 'T' + race.Sprint.time) : null;

            if(raceDateTime >= now && (qualifyingDateTime < now && (sprintDateTime === null || sprintDateTime < now))) {
                return true;
            }

            return false;
        });

        return nextRaces.length > 0 ? nextRaces[0] : null;
    }

    private getLastResult() {

        const now = new Date();

        console.log('Getting last result, schedule and season');

        Promise.all([this.client.GetSchedule(now.getFullYear()), this.client.GetLastResult()])
            .then(([schedule, lastResult]) => {
                
                const upcomingRace = this.getUpcomingRace(now, schedule);

                let season : number = new Date().getFullYear();
                let round : number = upcomingRace !== null ? parseInt(upcomingRace.round) : 0;
    
                let race = { } as Race;
                if(upcomingRace !== null) {
                    race = upcomingRace;
                    round = parseInt(race.round);
                    season = parseInt(race.season);
                } else if(lastResult !== null) {
                    race = lastResult;
                    round = parseInt(lastResult.round);
                    season = parseInt(lastResult.season);
                }
                
                Promise.all([this.resultsClient.GetQualifyingResults(season, round), 
                            this.resultsClient.GetSprintResults(season, round),
                            this.resultsClient.GetSeasonRaces(season)])
                    .then(([qualifyingResults, sprintResults, seasonRaces]) => {
                                                
                        const { properties, cardValues } = this.getParentCardValues();

                        race.QualifyingResults = qualifyingResults.Races[0].QualifyingResults;
                        race.SprintResults = sprintResults.Races[0]?.SprintResults;
                        
                        properties.races = seasonRaces;
                        properties.selectedRace = race;
                        properties.selectedSeason = season.toString();

                        console.log('Selected race: ' + race.raceName);
                        
                        cardValues.set('cardValues', properties);
                        this.parent.properties = cardValues;
                    });
        });        
    }

    setSelectedTabIndex(index: number) {        
        const { properties, cardValues } = this.getParentCardValues();
        properties.selectedTabIndex = index;
        cardValues.set('cardValues', properties);
        this.parent.properties = cardValues;
    }       

    icon(key: string) : string {

        if(!this.config.icons || Object.keys(this.config.icons).indexOf(key) < 0) {
            return this.icons[key];
        }

        return this.config.icons[key];
    }

    tabOrder(tab: string) : number {
        const tabsOrder = this.config.tabs_order?.map(tab => tab.toLowerCase()) ?? ['results', 'qualifying', 'sprint'];

        return tabsOrder.indexOf(tab);
    }
}


================================================
FILE: src/cards/schedule.ts
================================================
import { formatTime, HomeAssistant } from "custom-card-helpers";
import { html, HTMLTemplateResult } from "lit-html";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from "..";
import { Circuit, Race } from "../api/f1-models";
import { formatDate } from "../lib/format_date";
import { PreviousRaceDisplay } from "../types/formulaone-card-types";
import { getApiErrorMessage, getApiLoadingMessage, getCountryFlagByName, getEndOfSeasonMessage, reduceArray } from "../utils";
import { BaseCard } from "./base-card";

export default class Schedule extends BaseCard {
    hass: HomeAssistant;
    defaultTranslations = {
        'date' : 'Date',   
        'round' : 'Race',
        'time' : 'Time',
        'location' : 'Location',
        'endofseason' : 'Season is over. See you next year!'
    };

    constructor(parent: FormulaOneCard) {
        super(parent);    
    }    
    
    cardSize(): number {
        return 12;
    }

    renderLocation(circuit: Circuit) {
        const locationConcatted = html`${(this.config.standings?.show_flag ? html`<img height="10" width="20" src="${getCountryFlagByName(this, circuit.Location.country)}">&nbsp;` : '')}${circuit.Location.locality}, ${circuit.Location.country}`;
        return this.config.location_clickable ? html`<a href="${circuit.url}" target="_blank">${locationConcatted}</a>` : locationConcatted;
    }

    renderScheduleRow(race: Race): HTMLTemplateResult {
        const raceDate = new Date(race.date + 'T' + race.time);
        const renderClass = this.config.previous_race && raceDate < new Date() ? this.config.previous_race : '';

        return html`
            <tr class="${renderClass}">
                <td class="width-50 text-center">${race.round}</td>
                <td>${race.Circuit.circuitName}</td>
                <td>${this.renderLocation(race.Circuit)}</td>
                <td class="width-60 text-center">${formatDate(raceDate, this.hass.locale, this.config.date_locale)}</td>
                <td class="width-50 text-center">${formatTime(raceDate, this.hass.locale)}</td>
            </tr>`;
    }

    render() : HTMLTemplateResult {

        return html`${until(
            this.client.GetSchedule(new Date().getFullYear()).then(response => {

                const schedule = this.config.previous_race === PreviousRaceDisplay.Hide ? response.filter(race => {
                    return new Date(race.date + 'T' + race.time) >= new Date();
                }) : response;

                const next_race = schedule.filter(race =>  {
                    return new Date(race.date + 'T' + race.time) >= new Date();
                })[0];
                if(!next_race) {
                    return getEndOfSeasonMessage(this.translation('endofseason'));
                }

                return html`<table>
                            <thead>
                                <tr>
                                    <th>&nbsp;</th>
                                    <th>${this.translation('round')}</th>
                                    <th>${this.translation('location')}</th>
                                    <th class="text-center">${this.translation('date')}</th>
                                    <th class="text-center">${this.translation('time')}</th>
                                </tr>
                            </thead>
                            <tbody>
                                ${reduceArray(schedule, this.config.row_limit).map(race => this.renderScheduleRow(race))}
                            </tbody>
                        </table>`;
            }).catch(() => html`${getApiErrorMessage('schedule')}`),
            html`${getApiLoadingMessage()}`
        )}`;
    }
}

================================================
FILE: src/consts.ts
================================================
export const CARD_NAME = 'formulaone-card';
export const CARD_EDITOR_NAME = `${CARD_NAME}-editor`;

================================================
FILE: src/directives/action-handler-directive.ts
================================================
/* istanbul ignore file */
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types';
import { fireEvent } from 'custom-card-helpers';
import { ActionHandlerElement } from '../types/formulaone-card-types';
import { noChange } from 'lit';

const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0;

declare global {
  interface HASSDomEvents {
    action: ActionHandlerDetail;
  }
}

class ActionHandler extends HTMLElement implements ActionHandler {
  public holdTime = 500;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  public ripple: any;

  protected timer?: number;

  protected held = false;

  private dblClickTimeout?: number;

  constructor() {
    super();
    this.ripple = document.createElement('mwc-ripple');
  }

  public connectedCallback(): void {
    Object.assign(this.style, {
      position: 'absolute',
      width: isTouch ? '100px' : '50px',
      height: isTouch ? '100px' : '50px',
      transform: 'translate(-50%, -50%)',
      pointerEvents: 'none',
      zIndex: '999',
    });

    this.appendChild(this.ripple);
    this.ripple.primary = true;

    ['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach((ev) => {
      document.addEventListener(
        ev,
        () => {
          clearTimeout(this.timer);
          this.stopAnimation();
          this.timer = undefined;
        },
        { passive: true },
      );
    });
  }

  public bind(element: ActionHandlerElement, options: ActionHandlerOptions): void {
    if (element.actionHandler) {
      return;
    }
    element.actionHandler = true;

    element.addEventListener('contextmenu', (ev: Event) => {
      const e = ev || window.event;
      if (e.preventDefault) {
        e.preventDefault();
      }
      if (e.stopPropagation) {
        e.stopPropagation();
      }
      e.cancelBubble = true;
      e.returnValue = false;
      return false;
    });

    const start = (ev: Event): void => {
      this.held = false;
      let x: number;
      let y: number;
      if ((ev as TouchEvent).touches) {
        x = (ev as TouchEvent).touches[0].pageX;
        y = (ev as TouchEvent).touches[0].pageY;
      } else {
        x = (ev as MouseEvent).pageX;
        y = (ev as MouseEvent).pageY;
      }

      this.timer = window.setTimeout(() => {
        this.startAnimation(x, y);
        this.held = true;
      }, this.holdTime);
    };

    const end = (ev: Event): void => {
      // Prevent mouse event if touch event
      ev.preventDefault();
      if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) {
        return;
      }
      clearTimeout(this.timer);
      this.stopAnimation();
      this.timer = undefined;
      if (this.held) {
        fireEvent(element, 'action', { action: 'hold' });
      } else if (options.hasDoubleClick) {
        if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) {
          this.dblClickTimeout = window.setTimeout(() => {
            this.dblClickTimeout = undefined;
            fireEvent(element, 'action', { action: 'tap' });
          }, 250);
        } else {
          clearTimeout(this.dblClickTimeout);
          this.dblClickTimeout = undefined;
          fireEvent(element, 'action', { action: 'double_tap' });
        }
      } else {
        fireEvent(element, 'action', { action: 'tap' });
      }
    };

    const handleEnter = (ev: KeyboardEvent): void => {
      if (ev.keyCode !== 13) {
        return;
      }
      end(ev);
    };

    element.addEventListener('touchstart', start, { passive: true });
    element.addEventListener('touchend', end);
    element.addEventListener('touchcancel', end);

    element.addEventListener('mousedown', start, { passive: true });
    element.addEventListener('click', end);

    element.addEventListener('keyup', handleEnter);
  }

  private startAnimation(x: number, y: number): void {
    Object.assign(this.style, {
      left: `${x}px`,
      top: `${y}px`,
      display: null,
    });
    this.ripple.disabled = false;
    this.ripple.active = true;
    this.ripple.unbounded = true;
  }

  private stopAnimation(): void {
    this.ripple.active = false;
    this.ripple.disabled = true;
    this.style.display = 'none';
  }
}

// TODO You need to replace all instances of "action-handler-boilerplate" with "action-handler-<your card name>"
customElements.define('action-handler-formulaonecard', ActionHandler);

const getActionHandler = (): ActionHandler => {
  const body = document.body;
  if (body.querySelector('action-handler-formulaonecard')) {
    return body.querySelector('action-handler-formulaonecard') as ActionHandler;
  }

  const actionhandler = document.createElement('action-handler-formulaonecard');
  body.appendChild(actionhandler);

  return actionhandler as ActionHandler;
};

export const actionHandlerBind = (element: ActionHandlerElement, options?: ActionHandlerOptions): void => {
  const actionhandler: ActionHandler = getActionHandler();
  if (!actionhandler) {
    return;
  }
  actionhandler.bind(element, options);
};

export const actionHandler = directive(
  class extends Directive {
    update(part: AttributePart, [options]: DirectiveParameters<this>) {
      actionHandlerBind(part.element as ActionHandlerElement, options);
      return noChange;
    }

    // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
    render(_options?: ActionHandlerOptions) {}
  },
);

================================================
FILE: src/editor.ts
================================================
import EditorForm from '@marcokreeft/ha-editor-formbuilder';
import { FormControlType } from "@marcokreeft/ha-editor-formbuilder/dist/interfaces";
import { getDropdownOptionsFromEnum } from "@marcokreeft/ha-editor-formbuilder/dist/utils/entities";
import { css, CSSResult } from "lit";
import { html, TemplateResult } from "lit-html";
import { customElement } from 'lit/decorators.js';
import { CARD_EDITOR_NAME } from "./consts";
import { CountdownType, FormulaOneCardType, PreviousRaceDisplay, WeatherUnit } from "./types/formulaone-card-types";

@customElement(CARD_EDITOR_NAME)
export class FormulaOneCardEditor extends EditorForm {

    protected render(): TemplateResult {
        if (!this._hass || !this._config) {
            return html``;
        }

        return this.renderForm([
            { controls: [{ label: "Card Type (Required)", configValue: "card_type", type: FormControlType.Dropdown, items: getDropdownOptionsFromEnum(FormulaOneCardType) }] },
            { controls: [{ label: "Title", configValue: "title", type: FormControlType.Textbox }] },
            {
                label: "Basic configuration",
                cssClass: 'side-by-side',
                controls: [
                    { label: "Use F1 font", configValue: "f1_font", type: FormControlType.Switch },
                    { label: "Image clickable", configValue: "image_clickable", type: FormControlType.Switch },
                    { label: "Show carnumber", configValue: "show_carnumber", type: FormControlType.Switch },
                    { label: "Location clickable", configValue: "location_clickable", type: FormControlType.Switch },
                    { label: "Show race information", configValue: "show_raceinfo", type: FormControlType.Switch },
                    { label: "Hide track layout", configValue: "hide_tracklayout", type: FormControlType.Switch },
                    { label: "Hide race dates and times", configValue: "hide_racedatetimes", type: FormControlType.Switch },
                    { label: "Show last years result", configValue: "show_lastyears_result", type: FormControlType.Switch },
                    { label: "Only show date", configValue: "only_show_date", type: FormControlType.Switch },
                    { type: FormControlType.Filler },
                    { label: "Row limit", configValue: "row_limit", type: FormControlType.Textbox },
                    { label: "Date locale", configValue: "date_locale", type: FormControlType.Textbox }
                ]
            },
            {
                label: "Countdown Type",
                hidden: this._config.card_type !== FormulaOneCardType.Countdown,
                cssClass: 'side-by-side',
                controls: [{ configValue: "countdown_type", type: FormControlType.Checkboxes, items: getDropdownOptionsFromEnum(CountdownType) }]
            },
            {
                hidden: this._config.card_type !== FormulaOneCardType.NextRace,
                controls: [
                    { label: "Next race delay", configValue: "next_race_delay", type: FormControlType.Textbox },
                ]
            },
            {
                hidden: this._config.card_type !== FormulaOneCardType.Schedule,
                controls: [{ label: "Previous race", configValue: "previous_race", type: FormControlType.Dropdown, items: getDropdownOptionsFromEnum(PreviousRaceDisplay) }]
            },
            {
                label: "Standings",
                hidden: this._config.card_type !== FormulaOneCardType.ConstructorStandings && this._config.card_type !== FormulaOneCardType.DriverStandings,
                cssClass: 'side-by-side',
                controls: [
                    { label: "Show team", configValue: "standings.show_team", type: FormControlType.Switch },
                    { label: "Show flag", configValue: "standings.show_flag", type: FormControlType.Switch },
                    { label: "Show teamlogo", configValue: "standings.show_teamlogo", type: FormControlType.Switch }
                ]
            },
            {
                label: "Weather",
                hidden: this._config.card_type !== FormulaOneCardType.NextRace && this._config.card_type !== FormulaOneCardType.Countdown,
                controls: [
                    { label: "Show weather", configValue: "show_weather", type: FormControlType.Switch }
                ]
            },
            {
                cssClass: 'side-by-side',
                hidden: (this._config.card_type !== FormulaOneCardType.NextRace && this._config.card_type !== FormulaOneCardType.Countdown) || !this._config.show_weather,
                controls: [
                    { label: "API key", configValue: "weather_options.api_key", type: FormControlType.Textbox },
                    { label: "Unit", configValue: "weather_options.unit", type: FormControlType.Dropdown, items: getDropdownOptionsFromEnum(WeatherUnit) },
                    { label: "Show icon", configValue: "weather_options.show_icon", type: FormControlType.Switch },
                    { label: "Show precipitation", configValue: "weather_options.show_precipitation", type: FormControlType.Switch },
                    { label: "Show wind", configValue: "weather_options.show_wind", type: FormControlType.Switch },
                    { label: "Show temperature", configValue: "weather_options.show_temperature", type: FormControlType.Switch },
                    { label: "Show cloud coverage", configValue: "weather_options.show_cloud_cover", type: FormControlType.Switch },
                    { label: "Show visibility", configValue: "weather_options.show_visibility", type: FormControlType.Switch }
                ]
            },
            {
                label: "Tabs",
                hidden: this._config.card_type !== FormulaOneCardType.Results,
                controls: [
                    { label: "Tabs order", configValue: "tabs_order", type: FormControlType.Textbox }
                ]
            },
        ]);
    }

    static get styles() : CSSResult {
        return css`
            .form-row {
                margin-bottom: 10px;
            }
            .form-control {
                display: flex;
                align-items: center;
            }
            ha-switch {
                padding: 16px 6px;
            }
            .side-by-side {
                display: flex;
                flex-flow: row wrap;
            }            
            .side-by-side > label {
                width: 100%;
            }
            .side-by-side > .form-control {
                width: 49%;
                padding: 2px;
            }
            ha-textfield { 
                width: 100%;
            }
            .hidden {
                display: none;
            }
            @media (max-width: 600px) {
                .side-by-side > .form-control {
                    width: 48%;
                }
            }
        `;
    }
}

================================================
FILE: src/fonts.ts
================================================
export const loadCustomFonts = () => {
    
    if(window && document.fonts) {
        // Load the F1 font using the CSS Font Loading API
        const font = new FontFace("F1Bold", "url(https://www.formula1.com/etc/designs/fom-website/fonts/F1Bold/Formula1-Bold.woff)");
        document.fonts.add(font);
        font.load();
    }
}


================================================
FILE: src/index.ts
================================================
import * as packageJson from '../package.json';
import { property, customElement } from 'lit/decorators.js';
import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
import { FormulaOneCardConfig, FormulaOneCardType } from './types/formulaone-card-types';
import { CSSResult, html, HTMLTemplateResult, LitElement, PropertyValues } from 'lit';
import { checkConfig, hasConfigOrCardValuesChanged } from './utils';
import { loadCustomFonts } from './fonts';
import { styles } from './styles';
import ConstructorStandings from './cards/constructor-standings';
import DriverStandings from './cards/driver-standings';
import Schedule from './cards/schedule';
import NextRace from './cards/next-race';
import LastResult from './cards/last-result';
import { BaseCard } from './cards/base-card';
import Countdown from './cards/countdown';
import Results from './cards/results';
import RestCountryClient from './api/restcountry-client';
import { CARD_EDITOR_NAME, CARD_NAME } from './consts';

console.info(
    `%c ${CARD_NAME.toUpperCase()} %c ${packageJson.version}`,
    'color: cyan; background: black; font-weight: bold;',
    'color: darkblue; background: white; font-weight: bold;'
);

/* eslint-disable @typescript-eslint/no-explicit-any */
(window as any).customCards = (window as any).customCards || [];
(window as any).customCards.push({
  type: 'formulaone-card',
  name: 'FormulaOne card',
  preview: false,
  description: 'Present the data of Formula One in a pretty way',
});
/* eslint-enable @typescript-eslint/no-explicit-any */

@customElement(CARD_NAME)
export default class FormulaOneCard extends LitElement {
    @property() _hass?: HomeAssistant;
    @property() config?: FormulaOneCardConfig;
    @property() card: BaseCard;
    @property() warning: string;
    @property() set properties(values: Map<string, unknown>) {
        this._cardValues = values;
        this.update(values);
    }
    get properties() {
        return this._cardValues;
    }

    constructor() {
        super();
        
        this.setCountryCache();
    }

    private _cardValues?: Map<string, unknown>;

    /* istanbul ignore next */
    public static async getConfigElement(): Promise<LovelaceCardEditor> {
        await import("./editor");
        return document.createElement(CARD_EDITOR_NAME) as LovelaceCardEditor;
    }

    setConfig(config: FormulaOneCardConfig) {

        checkConfig(config);

        this.config = { ...config };
    }

    setCountryCache() {
        new RestCountryClient().GetAll().catch(() => { 
            this.warning = 'Country API is down, so flags are not available at the moment!'; 
            this.update(this._cardValues);
        });
    }

    protected shouldUpdate(changedProps: PropertyValues): boolean {
        return hasConfigOrCardValuesChanged(this, changedProps);
    }

    set hass(hass: HomeAssistant) {
        this._hass = hass;

        this.config.hass = hass;

        switch(this.config.card_type) {
            case FormulaOneCardType.ConstructorStandings:
                this.card = new ConstructorStandings(this);
                break;
            case FormulaOneCardType.DriverStandings:
                this.card = new DriverStandings(this);
                break;
            case FormulaOneCardType.Schedule:
                this.card = new Schedule(this);
                break;
            case FormulaOneCardType.NextRace:
                this.card = new NextRace(this);
                break;
            case FormulaOneCardType.LastResult:
                this.card = new LastResult(this);
                break;
            case FormulaOneCardType.Countdown:
                this.card = new Countdown(this);
                break;
            case FormulaOneCardType.Results:
                this.card = new Results(this);
                break;
        }
    }

    static get styles(): CSSResult {
        loadCustomFonts();
        return styles;
    }

    render() : HTMLTemplateResult {
        if (!this._hass || !this.config) return html``;

        try {
            return html`
                <ha-card elevation="2">
                    ${this.renderRefreshButton()}
                    ${this.warning ? html`<hui-warning>${this.warning}</hui-warning>` : ''}
                    ${this.config.title ? html`<h1 class="card-header${(this.config.f1_font ? ' formulaone-font' : '')}">${this.config.title}</h1>` : ''}
                    ${this.card.render()}
                </ha-card>
            `;
        } catch (error) {
            return html`<hui-warning>${error.toString()}</hui-warning>`;
        }
    }

    getCardSize() {
        return this.card.cardSize();
    }

    /* istanbul ignore next */
    renderRefreshButton() {
        return this.config.show_refresh ? html`<div class="refresh-cache" @click=${(e: Event) => this.refreshCache(e)}><ha-icon slot="icon" icon="mdi:refresh"></ha-icon></div>` : null;
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    refreshCache(event: Event) {
        console.log('Refreshing cache...');

       this.card.client.RefreshCache();
    }
}

================================================
FILE: src/lib/constants.ts
================================================
export const ImageConstants = {
    FlagCDN : 'https://flagcdn.com/w320/',
    TeamLogoCDNLegacy : 'https://www.formula1.com/content/dam/fom-website/teams/',
    TeamLogoCDN : 'https://media.formula1.com/image/upload/c_lfill,w_48/q_auto/v1740000000/common/f1/',
    F1CDNLegacy : 'https://media.formula1.com/image/upload/f_auto,c_limit,q_auto,w_1320/content/dam/fom-website/2018-redesign-assets/Circuit%20maps%2016x9',
    F1CDN: 'https://media.formula1.com/image/upload/c_fit,h_704/q_auto/v1740000001/common/f1/2026/track/2026track'
};

export const TIMESTAMP_FORMATS = ['relative', 'total', 'date', 'time', 'datetime'];

export const SECONDARY_INFO_VALUES = [
    'entity-id',
    'last-changed',
    'last-updated',
    'last-triggered',
    'position',
    'tilt-position',
    'brightness',
];

export const NumberFormat = {
    language: 'language',
    system: 'system',
    comma_decimal: 'comma_decimal',
    decimal_comma: 'decimal_comma',
    space_comma: 'space_comma',
    none: 'none',
};

export const TimeFormat = {
    language: 'language',
    system: 'system',
    am_pm: '12',
    twenty_four: '24',
};


================================================
FILE: src/lib/format_date.ts
================================================
// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_date.ts
import { FrontendLocaleData } from 'custom-card-helpers';

export const formatDate = (dateObj: Date, locale: FrontendLocaleData, overrideLanguage?: string) => new Intl.DateTimeFormat(overrideLanguage ?? locale.language, {
    month: '2-digit',
    day: '2-digit',
}).format(dateObj);

export const formatDateNumeric = (dateObj: Date, locale: FrontendLocaleData, overrideLanguage?: string) => new Intl.DateTimeFormat(overrideLanguage ?? locale.language, {
    year: "2-digit",
    month: "2-digit",
    day: "2-digit",
}).format(dateObj);

================================================
FILE: src/lib/format_date_time.ts
================================================
// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_date_time.ts

import { FrontendLocaleData } from 'custom-card-helpers';
import { useAmPm } from './use_am_pm';

export const formatDateTime = (dateObj: Date, locale: FrontendLocaleData) => new Intl.DateTimeFormat(locale.language, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: useAmPm(locale) ? 'numeric' : '2-digit',
    minute: '2-digit',
    hour12: useAmPm(locale),
}).format(dateObj);

export const formatDateTimeRaceInfo = (dateObj: Date, locale: FrontendLocaleData) => new Intl.DateTimeFormat(locale.language, {        
    weekday: 'short',
    hour: '2-digit',
    minute: '2-digit',
    hour12: useAmPm(locale),
}).format(dateObj);

================================================
FILE: src/lib/format_time.ts
================================================
// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_time.ts
import { FrontendLocaleData } from 'custom-card-helpers';

export const formatTime = (dateObj: Date, locale: FrontendLocaleData) => new Intl.DateTimeFormat(locale.language, {
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
}).format(dateObj);


================================================
FILE: src/lib/use_am_pm.ts
================================================
// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/use_am_pm.ts
import { FrontendLocaleData } from 'custom-card-helpers';
import { TimeFormat } from './constants';

export const useAmPm = (locale: FrontendLocaleData) => {
    if (locale.time_format === TimeFormat.language || locale.time_format === TimeFormat.system) {
        const testLanguage = locale.time_format === TimeFormat.language ? locale.language : undefined;
        const test = new Date().toLocaleString(testLanguage);
        return test.includes('AM') || test.includes('PM');
    }

    return locale.time_format === TimeFormat.am_pm;
};


================================================
FILE: src/styles.ts
================================================
import { css } from 'lit';

export const styles = css`   
    table {
        width: 100%;
        border-spacing: 0;
        border-collapse: separate;
        padding: 0px 16px 16px;
    }
    table.nopadding {
        padding: 0px;
        width: 100%;
        border-spacing: 0;
        border-collapse: separate;
    }
    th {
        background-color: var(--table-row-alternative-background-color, #eee);
    }
    th, td {
        padding: 5px;
        text-align: left;
    }
    tr {
        color: var(--secondary-text-color);
    }
    tr:nth-child(even) {
        background-color: var(--table-row-alternative-background-color, #eee);
    }
    .text-center {
        text-align: center;
    }
    .width-40 {
        width: 40px;
    }
    .width-50 {
        width: 50px;
    }
    .width-60 {
        width: 60px;
    }
    .hide {
        display: none;
    }
    .strikethrough {
        text-decoration: line-through;
    }
    .italic {
        font-style: italic;
    }
    a {
        text-decoration: none;
        color: var(--secondary-text-color);
    }
    .constructor-logo {
        width: 20px;
        margin: auto;
        display: block;
        float: left;
        background-color: white;
        border-radius: 50%;
        margin-right: 3px;
    }
    .clickable {
        cursor: pointer;
    }
    .formulaone-font {
        font-family: 'F1Bold';
    }
    ha-icon {
        color: var(--secondary-text-color);
    }
    .transparent {
        background-color: transparent !important;
    }        
    .weather-info {
        padding: 10px;
    }

    .weather-info td {
        width: 33%;
    }
    .refresh-cache {
        position: absolute;
        right: 10px;
        top: 10px;
    }
`;


================================================
FILE: src/types/formulaone-card-types.ts
================================================
import { ActionConfig, ActionHandlerOptions, HomeAssistant, LovelaceCardConfig } from 'custom-card-helpers';
import { HTMLTemplateResult } from 'lit';

export interface FormulaOneCardConfig extends LovelaceCardConfig {
    source: F1DataSource;
    entity?: string;
    show_icon?: boolean;
    title?: string;
    name?: string;
    hass?: HomeAssistant;
    card_type?: FormulaOneCardType;
    date_locale?: string;
    image_clickable?: boolean;
    show_carnumber?: boolean;
    location_clickable?: boolean;
    previous_race?: PreviousRaceDisplay;
    standings?: StandingDisplayOptions;
    translations?: Translation;
    show_raceinfo?: boolean;
    hide_tracklayout?: boolean;
    hide_racedatetimes?: boolean;
    actions?: ActionOptions;
    f1_font?: boolean; 
    row_limit?: number;
    icons?: CustomIcons;
    countdown_type?: CountdownType | CountdownType[] | undefined;
    show_event_details?: boolean;
    show_weather?: boolean;
    weather_options?: WeatherOptions;
    next_race_delay?: number;
    show_lastyears_result?: boolean;
    only_show_date?: boolean;
    tabs_order?: string[];
    show_refresh?: boolean;
    next_race_display?: NextRaceDisplay | undefined;
    countdown_format?: string;
}

export enum F1DataSource {
    Jolpi = 'jolpi',
    F1Sensor = 'f1sensor'
}

export interface ValueChangedEvent {
    detail: {
        value: {
            itemValue: string;
            parentElement: {
                configValue: string;
            };
        }
    };
    target: {
        value: string;
        configValue: string;
        checked?: boolean;
    };    
}

export interface WeatherOptions {
    source: WeatherSource;
    entity?: string;
    api_key?: string;
    unit?: WeatherUnit;
    show_icon?: boolean;
    show_precipitation?: boolean; 
    show_wind?: boolean;
    show_temperature?: boolean;
    show_cloud_cover?: boolean;
    show_visibility?: boolean;
}

export enum WeatherSource {
    VisualCrossing = 'visualcrossing',
    F1Sensor = 'f1sensor',
}

export enum NextRaceDisplay {
    DateOnly = 'date',
    TimeOnly = 'time',
    DateAndTime = 'datetime'
}

export enum WeatherUnit {
    Metric = 'metric',
    MilesCelsius = 'uk',
    MilesFahrenheit = 'us'
}

export enum CountdownType {
    Race = "race",
    Qualifying = "qualifying",
    Practice1 = "practice1",
    Practice2 = "practice2",
    Practice3 = "practice3",
    Sprint = "sprint",
    SprintQualifying = "sprint_qualifying"
}

export interface ActionOptions {
    tap_action?: ActionConfig;
    hold_action?: ActionConfig;
    double_tap_action?: ActionConfig;
}

export interface Translation {
    [key: string]: string;
}

export interface CustomIcons {
    [key: string]: string;
}

export interface StandingDisplayOptions {
    show_team?: boolean;
    show_flag?: boolean;
    show_teamlogo?: boolean;
    hide_season_selector?: boolean;
}

export enum PreviousRaceDisplay {
    Strikethrough = 'strikethrough',
    Italic = 'italic',
    Hide = 'hide'
}

export enum FormulaOneCardType {
    DriverStandings = 'driver_standings',
    ConstructorStandings = 'constructor_standings',
    NextRace = 'next_race',
    Schedule = 'schedule',
    LastResult = 'last_result',
    Results = 'results',
    Countdown = 'countdown'
}

export interface LocalStorageItem {
    data: string,
    created: Date
}

export interface CardProperties {
    [key: string]: unknown;
}

export interface ActionHandler extends HTMLElement {
    holdTime: number;
    bind(element: Element, options: ActionHandlerOptions): void;
}

export interface ActionHandlerElement extends HTMLElement {
    actionHandler?: boolean;
}

export interface FormulaOneCardTab {
    title: string
    icon: string
    content: HTMLTemplateResult,
    hide?: boolean,
    order?: number
}

export interface SelectChangeEvent {
    target: {
        value: string;
    }
}

export interface mwcTabBarEvent extends Event {
    detail: {
        index: number;
    };
}

================================================
FILE: src/types/rest-country-types.ts
================================================
export interface Flags {
    svg: string;
    png: string;
}

export interface Currency {
    code: string;
    name: string;
    symbol: string;
}

export interface Language {
    iso639_1: string;
    iso639_2: string;
    name: string;
    nativeName: string;
}

export interface Translations {
    br: string;
    pt: string;
    nl: string;
    hr: string;
    fa: string;
    de: string;
    es: string;
    fr: string;
    ja: string;
    it: string;
    hu: string;
}

export interface RegionalBloc {
    acronym: string;
    name: string;
    otherNames: string[];
    otherAcronyms: string[];
}

export interface Country {
    name: string;
    topLevelDomain: string[];
    alpha2Code: string;
    alpha3Code: string;
    callingCodes: string[];
    capital: string;
    altSpellings: string[];
    subregion: string;
    region: string;
    population: number;
    latlng: number[];
    demonym: string;
    area: number;
    timezones: string[];
    borders: string[];
    nativeName: string;
    numericCode: string;
    flags: Flags;
    currencies: Currency[];
    languages: Language[];
    translations: Translations;
    flag: string;
    regionalBlocs: RegionalBloc[];
    cioc: string;
    independent: boolean;
    gini?: number;
}

================================================
FILE: src/utils.ts
================================================
import { ActionHandlerEvent, handleAction, hasAction, HomeAssistant } from "custom-card-helpers";
import { html, HTMLTemplateResult, LitElement, PropertyValues } from "lit";
import { until } from 'lit-html/directives/until.js';
import FormulaOneCard from ".";
import { Constructor, Driver, Location, Race, Root } from "./api/f1-models";
import RestCountryClient from "./api/restcountry-client";
import { WeatherData } from "./api/weather-models";
import { BaseCard } from "./cards/base-card";
import { actionHandler } from './directives/action-handler-directive';
import { ImageConstants } from "./lib/constants";
import { formatDateNumeric } from "./lib/format_date";
import { formatDateTimeRaceInfo } from "./lib/format_date_time";
import { FormulaOneCardConfig, FormulaOneCardType, LocalStorageItem, Translation } from "./types/formulaone-card-types";

export const hasConfigOrCardValuesChanged = (node: FormulaOneCard, changedProps: PropertyValues) => {
    if (changedProps.has('config')) {
        return true;
    }

    const card = changedProps.get('card') as BaseCard;
    if (card && card.parent) {
        return card.parent.properties !== node.properties;
    }

    const cardValues = changedProps.get('cardValues') as Map<string, unknown>;
    if(cardValues) {
        return cardValues != node.properties;
    }

    return false;
};

export const getCountries = () => {
    const countryClient = new RestCountryClient();
    return countryClient.GetCountriesFromLocalStorage();
}

export const getCountryFlagByNationality = (card: BaseCard, nationality: string) => {
    const countries = getCountries();

    nationality = nationality.trim();
    const exceptions = [{ demonym: 'Argentinian', corrected: 'Argentinean'}, { demonym: 'Argentine', corrected: 'Argentinean'}];
    const exception = exceptions.filter(exception => exception.demonym == nationality);
    if(exception.length > 0)
    {
        nationality = exception[0].corrected;
    }
    
    const country = countries.filter(x => x.demonym == nationality);
    if(country.length > 1)
    {
        return card.imageClient.GetImage(country.sort((a, b) => (a.population > b.population) ? -1 : 1)[0].flags.png);
    }    

    return card.imageClient.GetImage(country[0].flags.png);
}

export const getCountryFlagByName = (card: BaseCard, countryName: string) => {
    const countries = getCountries();
    
    const country = countries.filter(x => x.name == countryName || x.nativeName == countryName ||
        x.altSpellings?.includes(countryName))[0];

    return card.imageClient.GetImage(country.flags.png);
}

export const checkConfig = (config: FormulaOneCardConfig) => {
    if (config.card_type === undefined) {
        throw new Error('Please define FormulaOne card type (card_type).');
    }
};

export const getTeamImage = (card: BaseCard, teamName: string, selectedSeason: number) => {
    return card.imageClient.GetTeamLogoImage(teamName, selectedSeason);
}

export const getCircuitName = (race: Race) => {
    
    const exceptions = [{ countryDashed: 'Spain', name: 'Catalunya'}, { countryDashed: 'Belgium', name: 'SpaFrancorchamps'}, { countryDashed: 'Hungary', name: 'Hungaroring'}, 
    { countryDashed: 'Brazil', name: 'Interlagos'}, { countryDashed: 'USA', name: 'LasVegas'}, { countryDashed: 'USA', name: 'Miami'}, { countryDashed: 'UAE', name: 'YasMarina'}, { countryDashed: 'Singapore', name: 'singapore'}];

    const exception = exceptions.filter(exception => exception.countryDashed == race.Circuit.Location.country);
    if(exception.length > 0)
    {
        if (exception.length > 1) {
            const circuitException = exception.filter(exception => exception.name.toLowerCase() == race.Circuit.Location.locality.toLowerCase());

            if(circuitException.length > 0) {
                return circuitException[0].name;
            }
        }

        return exception[0].name;
    }

    return race.Circuit.Location.locality.replace(" ","");
}


export const getCircuitNameLegacy = (location: Location) => {
    
    let circuitName = location.country.replace(" ","-")
    const exceptions = [{ countryDashed: 'UAE', name: 'Abu_Dhabi'}, { countryDashed: 'UK', name: 'Great_Britain'}, 
    { countryDashed: 'Azerbaijan', name: 'Baku'}, { countryDashed: 'Saudi-Arabia', name: 'Saudi_Arabia'}];

    const exception = exceptions.filter(exception => exception.countryDashed == circuitName);
    if(exception.length > 0)
    {
        circuitName = exception[0].name; 
    }

    if((location.country == 'USA' || location.country == 'United States') && location.locality != 'Austin')
    {
        circuitName = location.locality.replace(" ","_");
    }

    if(location.country == 'Italy' && location.locality == 'Imola')
    {
        circuitName = "Emilia_Romagna";
    }

    return circuitName;
}

export const getDriverName = (driver: Driver, config: FormulaOneCardConfig) => {
    const permanentNumber = driver.code == 'VER' ? 1 : driver.permanentNumber;
    return `${driver.givenName} ${driver.familyName}${(config.show_carnumber ? ` #${permanentNumber}` : '')}`;
}

export const getApiErrorMessage = (dataType: string) => {
    return html`<table><tr><td class="text-center"><ha-icon icon="mdi:alert-circle"></ha-icon> Error getting ${dataType} <ha-icon icon="mdi:alert-circle"></ha-icon></td></tr></table>`
}

export const getApiLoadingMessage = () => {
    return html`<table><tr><td class="text-center"><ha-icon icon="mdi:car-speed-limiter"></ha-icon> Loading... <ha-icon icon="mdi:car-speed-limiter"></ha-icon></td></tr></table>`
}

export const getEndOfSeasonMessage = (message: string) => {
    return html`<table><tr><td class="text-center"><ha-icon icon="mdi:flag-checkered"></ha-icon><strong>${message}</strong><ha-icon icon="mdi:flag-checkered"></ha-icon></td></tr></table>`;
} 

export const clickHandler = (node: LitElement, config: FormulaOneCardConfig, hass: HomeAssistant, ev: ActionHandlerEvent) => {
    handleAction(node, hass, config.actions, ev.detail.action);
}

export const renderHeader = (card: BaseCard, race: Race): HTMLTemplateResult => {
    const _handleAction = (ev: ActionHandlerEvent): void => {
        if (card.hass && card.config.actions && ev.detail.action && card.config.image_clickable) {
            clickHandler(card.parent, card.config, card.hass, ev);
        }
    }
    
    const hasConfigAction = card.config.image_clickable || card.config.actions !== undefined;
    const circuitUrl = race.Circuit.url;

    if(card.config.image_clickable && !card.config.actions) {
        card.config.actions = {
            tap_action: {
                action: 'url',
                url_path: circuitUrl
            }
        };
    }

    const imageHtml = html`<img width="100%" src="${card.imageClient.GetTrackLayoutImage(race)}" @action=${_handleAction}
    .actionHandler=${actionHandler({
        hasHold: hasAction(card.config.actions?.hold_action),
        hasDoubleClick: hasAction(card.config.actions?.double_tap_action),
      })} class="${(hasConfigAction ? ' clickable' : null)}" />`;
    const raceName = html`<h2 class="${(card.config.f1_font ? 'formulaone-font' : '')}"><img height="25" src="${getCountryFlagByName(card, race.Circuit.Location.country)}">&nbsp;  ${race.round} :  ${race.raceName}</h2>`;
    
    return html`${(card.config.card_type == FormulaOneCardType.Countdown ? html`` : raceName)} ${(card.config.hide_tracklayout ? html`` : imageHtml)}<br>`;
}

export const renderRaceInfo = (card: BaseCard, race: Race) => {
    const config = card.config;
    const hass = card.hass;
    const weatherPromise = config.show_weather ? card.weatherClient.getRaceWeatherData(card.config.weather_options, race) : Promise.resolve(null);
    const lastYearPromise = config.show_lastyears_result ? card.resultsClient.GetLastYearsResults(race.Circuit.circuitName) : Promise.resolve(null);

    const promises = Promise.all([weatherPromise, lastYearPromise]);
    
    return html`${until(promises.then(([weather, lastYearData]) => {
        
        const weatherInfo = renderWeatherInfo(weather);
        const lastYearsResult = renderLastYearsResults(config, lastYearData)

        if (config.hide_racedatetimes && (config.show_weather || config.show_lastyears_result)) 
             return html`${weatherInfo}${lastYearsResult}`;

        const raceDate = new Date(race.date + 'T' + race.time);

        const freePractice1Datetime = race.FirstPractice !== undefined ? new Date(race.FirstPractice.date + 'T' + race.FirstPractice.time) : null;
        const freePractice2Datetime = race.SecondPractice !== undefined ? new Date(race.SecondPractice.date + 'T' + race.SecondPractice.time) : null;
        const freePractice3Datetime = race.ThirdPractice !== undefined ? new Date(race.ThirdPractice.date + 'T' + race.ThirdPractice.time) : null;
        const qualifyingDatetime = race.Qualifying !== undefined ? new Date(race.Qualifying.date + 'T' + race.Qualifying.time) : null;
        const sprintQualifyingDatetime = race.SprintQualifying !== undefined ? new Date(race.SprintQualifying.date + 'T' + race.SprintQualifying.time) : null;
        const sprintDatetime = race.Sprint !== undefined ? new Date(race.Sprint.date + 'T' + race.Sprint.time) : null;

        const freePractice1 = race.FirstPractice !== undefined ? formatDateTimeRaceInfo(freePractice1Datetime, hass.locale) : '-';
        const freePractice2 = race.SecondPractice !== undefined ? formatDateTimeRaceInfo(freePractice2Datetime, hass.locale) : '-';
        const freePractice3 = race.ThirdPractice !== undefined ? formatDateTimeRaceInfo(freePractice3Datetime, hass.locale) : '-';
        const raceDateFormatted = formatDateTimeRaceInfo(raceDate, hass.locale);
        const qualifyingDate = formatDateTimeRaceInfo(qualifyingDatetime, hass.locale);
        const sprintDate = race.Sprint !== undefined ? formatDateTimeRaceInfo(sprintDatetime, hass.locale) : '-';
        const sprintQualifyingDate = race.SprintQualifying !== undefined ? formatDateTimeRaceInfo(sprintQualifyingDatetime, hass.locale) : '-';

        const events: { date: Date, name: string, value: string }[] = [];
        events.push({ date: freePractice1Datetime, name: card.translation('practice1'), value: freePractice1 });
        events.push({ date: freePractice2Datetime, name: card.translation('practice2'), value: freePractice2 });
        events.push({ date: freePractice3Datetime, name: card.translation('practice3'), value: freePractice3 });
        events.push({ date: qualifyingDatetime, name: card.translation('qualifying'), value: qualifyingDate });
        events.push({ date: sprintQualifyingDatetime, name: card.translation('sprint_qualifying'), value: sprintQualifyingDate });
        events.push({ date: sprintDatetime, name: card.translation('sprint'), value: sprintDate });
        events.push({ date: raceDate, name: card.translation('racetime'), value: raceDateFormatted });

        const filteredEvents = events.filter(event => event.date !== null).sort((a, b) => a.date.getTime() - b.date.getTime()); 
        
        return html`${lastYearsResult}${weatherInfo}<tr><td>${card.translation('date')}</td><td>${formatDateNumeric(raceDate, hass.locale, config.date_locale)}</td><td>&nbsp;</td><td>${renderEventColumn(0, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(0, 'value', filteredEvents)}</td></tr>
                    <tr><td>${card.translation('round')}</td><td>${race.round}</td><td>&nbsp;</td><td>${renderEventColumn(1, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(1, 'value', filteredEvents)}</td></tr>
                    <tr><td>${card.translation('racename')}</td><td>${race.raceName}</td><td>&nbsp;</td><td>${renderEventColumn(2, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(2, 'value', filteredEvents)}</td></tr>
                    <tr><td>${card.translation('circuitname')}</td><td>${race.Circuit.circuitName}</td><td>&nbsp;</td><td>${renderEventColumn(3, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(3, 'value', filteredEvents)}</td></tr>
                    <tr><td>${card.translation('location')}</td><td>${race.Circuit.Location.country}</td><td>&nbsp;</td><td>${renderEventColumn(4, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(4, 'value', filteredEvents)}</td></tr>        
                    <tr><td>${card.translation('city')}</td><td>${race.Circuit.Location.locality}</td><td>&nbsp;</td><td>${renderEventColumn(5, 'name', filteredEvents)}</td><td align="right">${renderEventColumn(5, 'value', filteredEvents)}</td></tr>`;
    }))}`;
}

export const renderEventColumn = (index: number, lookupKey: string, events: { date: Date, name: string, value: string }[]) => {
 
    if (events.length > index) {
        if(lookupKey === 'name') 
            return events[index].name;

        if(lookupKey === 'value') 
            return events[index].value;
    }

    return '-';
};

export const renderLastYearsResults = (config: FormulaOneCardConfig, raceData: Race) => {
    if(!raceData) {
        return html``;
    }

    const result = raceData.Results ? raceData.Results[0] : null;
    const fastest = raceData.Results?.filter((result) => result.FastestLap?.rank === '1')[0];

    return html`<tr>
        <td colspan="5">
            <table class="weather-info">
                <tr>
                    <td class="text-center">
                        <h1 class="${(config.f1_font ? 'formulaone-font' : '')}">${new Date(raceData.date).getFullYear()}</h1>
                        <h2 class="${(config.f1_font ? 'formulaone-font' : '')}">
                            <ha-icon slot="icon" icon="mdi:trophy-outline"></ha-icon> ${result?.Driver.givenName} ${result?.Driver.familyName} (${result?.Constructor.name})
                        </h2>
                        <h3 class="${(config.f1_font ? 'formulaone-font' : '')}">
                            <ha-icon slot="icon" icon="mdi:timer-outline"></ha-icon> ${fastest?.Driver.givenName} ${fastest?.Driver.familyName} (${fastest?.FastestLap?.Time.time})
                        </h3>
                    </td>
                </tr>
            </table>
        </td>
        <tr><td colspan="5">&nbsp;</td></tr>`;
}

export const renderWeatherInfo = (weatherData: WeatherData) => { 
    if(!weatherData) {
        return html``;
    }

    const tempUnit = weatherData.race_temperature_unit === 'fahrenheit' ? '°F' : '°C';

    return html`<tr>
                    <td colspan="5">
                        <table class="weather-info">
                            <tr>
                                <td><ha-icon slot="icon" icon="mdi:clouds"></ha-icon>&nbsp;${weatherData.race_cloud_cover} ${weatherData.race_cloud_cover_unit}</td>
                                <td><ha-icon slot="icon" icon="mdi:thermometer-lines"></ha-icon>&nbsp;${weatherData.race_temperature} ${tempUnit}</td>
                                <td><ha-icon slot="icon" icon="mdi:water-percent"></ha-icon>&nbsp;${weatherData.race_humidity} ${weatherData.race_humidity_unit}</td>
                            </tr>
                            <tr>
                                <td><ha-icon slot="icon" icon="mdi:weather-windy"></ha-icon>&nbsp;${weatherData.race_wind_direction} ${weatherData.race_wind_speed} ${weatherData.race_wind_speed_unit}</td>
                                <td><ha-icon slot="icon" icon="mdi:weather-pouring"></ha-icon>&nbsp;${weatherData.race_precipitation} ${weatherData.race_precipitation_unit}</td>
                                <td>${(weatherData.race_precipitation_prob ? html`<ha-icon slot="icon" icon="mdi:cloud-percent-outline"></ha-icon>&nbsp;${weatherData.race_precipitation_prob} %` : html``)}</td>
                            </tr>
                            
                        </table>
                    </td>
                </tr>
                <tr><td colspan="5">&nbsp;</td></tr>`;
}

export const getRefreshTime = (endpoint: string) => {
    let refreshCacheHours = 24;
    const now = new Date();
    const scheduleLocalStorage = localStorage.getItem(`${now.getFullYear()}.json`);

    if(scheduleLocalStorage) {
        const item: LocalStorageItem = <LocalStorageItem>JSON.parse(scheduleLocalStorage);
        const schedule = <Root>JSON.parse(item.data);
        const filteredRaces = schedule.MRData.RaceTable.Races.filter(race => new Date(race.date).toLocaleDateString == now.toLocaleDateString);
        
        if(filteredRaces.length > 0) {
            const todaysRace = filteredRaces[0];
            const raceTime = new Date(todaysRace.date + 'T' + todaysRace.time);
            
            const lastResultLocalStorage = localStorage.getItem(endpoint);  
            if(lastResultLocalStorage) {
                const resultItem: LocalStorageItem = <LocalStorageItem>JSON.parse(lastResultLocalStorage);
                
                if(new Date(resultItem.created) < raceTime) {
                    refreshCacheHours = 1;
                }
            }          
        }
    }

    return refreshCacheHours;
}

export const reduceArray = <T>(array?: T[], number?: number) => {
    if(array === undefined) {
        return [];
    }

    return number ? array.slice(0, number) : array;
}

export const renderConstructorColumn = (card: BaseCard, constructor: Constructor, selectedSeason: number): HTMLTemplateResult => {
    return html`<td>${(card.config.standings.show_teamlogo ? html`<img class="constructor-logo" height="20" width="20" src="${getTeamImage(card, constructor.constructorId, selectedSeason)}">&nbsp;` : '')}${constructor.name}</td>`;
}

export const translateStatus = (status: string, config: FormulaOneCardConfig) => {
    const defaultTranslations: Translation = {
        'Finished' : 'Finished',
        '+1 Lap' : '+1 Lap',
        'Engine' : 'Engine',
        '+2 Laps' : '+2 Laps',
        'Accident' : 'Accident',
        'Collision' : 'Collision',
        'Gearbox' : 'Gearbox',
        'Spun off' : 'Spun off',
        '+3 Laps' : '+3 Laps',
        'Suspension' : 'Suspension',
        '+4 Laps' : '+4 Laps',
        'Transmission' : 'Transmission',
        'Electrical' : 'Electrical',
        'Brakes' : 'Brakes',
        'Withdrew' : 'Withdrew',
        '+5 Laps' : '+5 Laps',
        'Clutch' : 'Clutch',
        'Lapped' : 'Lapped',
        'Retired' : 'Retired',
        'Not classified' : 'Not classified',
        'Fuel system' : 'Fuel system',
        '+6 Laps' : '+6 Laps',
        'Disqualified' : 'Disqualified',
        'Turbo' : 'Turbo',
        'Hydraulics' : 'Hydraulics',
        'Overheating' : 'Overheating',
        'Ignition' : 'Ignition',
        'Oil leak' : 'Oil leak',
        'Throttle' : 'Throttle',
        'Out of fuel' : 'Out of fuel'
    };

    if(!config.translations || Object.keys(config.translations).indexOf(status) < 0) {
        return defaultTranslations[status];
    }

    return config.translations[status]
}


================================================
FILE: test/configuration.yaml
================================================
homeassistant:
  time_zone: Europe/Amsterdam
  temperature_unit: C
  unit_system: metric

default_config:

lovelace:
  mode: storage
  dashboards:
    lovelace-yaml:
      mode: yaml
      title: yaml
      filename: test/lovelace.yaml

================================================
FILE: test/lovelace.yaml
================================================
views:
  - theme: Backend-selected
    title: f1
    path: f1
    icon: mdi:car-sports
    subview: false
    badges: []
    cards:
      - type: custom:formulaone-card
        card_type: countdown
        show_raceinfo: true
        f1_font: true
        date_locale: en-US
        show_weather: true
        weather_options:
          api_key: R69TG493HYLZWLP3UKKJDTPB2
        title: null
        show_lastyears_result: true
        hide_tracklayout: false
        show_refresh: false
      - type: custom:formulaone-card
        card_type: results
        date_locale: nl
        location_clickable: true
        f1_font: true
      - type: custom:formulaone-card
        card_type: schedule
        date_locale: nl
        previous_race: hide
        location_clickable: true
      - type: custom:formulaone-card
        card_type: constructor_standings
        standings:
          show_teamlogo: true
      - type: custom:formulaone-card
        card_type: driver_standings
        standings:
          show_flag: true
          show_team: true
          show_teamlogo: true


================================================
FILE: tests/api/ergast-client.test.ts
================================================
import ErgastClient from "../../src/api/ergast-client";
import LocalStorageMock from "../testdata/localStorageMock";
import fetchMock from "jest-fetch-mock";

// Models
import { Mrdata } from "../../src/api/f1-models";
import { LocalStorageItem } from '../../src/types/formulaone-card-types';

// Importing test data
import { MRData as scheduleData } from '../testdata/schedule.json'
import { MRData as resultData } from '../testdata/results.json'
import { MRData as driverStandingsData } from '../testdata/driverStandings.json'
import { MRData as constructorStandingsData } from '../testdata/constructorStandings.json'
import { MRData as seasonData } from '../testdata/seasons.json'
import { MRData as qualifyingData } from '../testdata/qualifying.json'

describe('Testing ergast client file', () => {
    const client = new ErgastClient();    
    const localStorageMock = new LocalStorageMock();

    Object.defineProperty(window, 'localStorage', { value: localStorageMock });

    beforeEach(() => {        
        localStorageMock.clear();     
    });

    test('Passing number to GetSchedule should return correct data', async () => { 
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const result = await client.GetSchedule(2022);

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(scheduleData.RaceTable.Races));
    }),
    test('Calling GetLastResult should return correct data', async () => {       
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>resultData }));

        // Act
        const result = await client.GetLastResult();

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(resultData.RaceTable.Races[0]));
    }),
    test('Calling GetDriverStandings should return correct data', async () => {       
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>driverStandingsData }));

        // Act
        const result = await client.GetDriverStandings();

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(driverStandingsData.StandingsTable.StandingsLists[0].DriverStandings));
    }),
    test('Calling GetConstructorStandings should return correct data', async () => {           
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>constructorStandingsData }));

        // Act
        const result = await client.GetConstructorStandings();

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(constructorStandingsData.StandingsTable.StandingsLists[0].ConstructorStandings));
    }),
    test('Calling GetResults should return correct data', async () => {  
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>resultData }));

        // Act
        const result = await client.GetResults(2022, 2);

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(resultData.RaceTable));
    }),
    test('Calling GetSeasons should return correct data', async () => {           
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>seasonData }));

        // Act
        const result = await client.GetSeasons();

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(seasonData.SeasonTable.Seasons));
    }),
    test('Calling GetSeasonRaces should return correct data', async () => {     
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const result = await client.GetSeasonRaces(2022);

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(scheduleData.RaceTable.Races));
    }),
    test('Calling GetQualifyingResults should return correct data', async () => {    
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>qualifyingData }));

        // Act
        const result = await client.GetQualifyingResults(2022, 2);

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(qualifyingData.RaceTable));
    }),
    test('Calling GetData with data in localstorage and cacheResult true should return correct data', async () => {      
        // Arrange
        localStorageMock.setItem('2022.json', JSON.stringify({ data: JSON.stringify({ MRData: scheduleData }), created: new Date() }));    

        // Act
        const result = await client.GetData('2022.json', true, 24);

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(scheduleData));
    }),
    test('Calling GetData without data in localstorage and cacheResult true should return correct data', async () => {  
        // Arrange 
        const endpoint = '2022.json';      
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));  
        
        // Act
        const result = await client.GetData(endpoint, true, 24);
        const localStorageItem = <LocalStorageItem>JSON.parse(localStorageMock.getItem(endpoint));

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(scheduleData));
        expect(localStorageItem.data).toMatch(JSON.stringify(scheduleData));
    }),
    test('Calling GetLastYearsResults without data in localstorage and cacheResult true should return correct data', async () => {  
        // Arrange      
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));  
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>resultData }));
        
        // Act
        const result = await client.GetLastYearsResults('Hungaroring');

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(resultData.RaceTable.Races[0]));
    })
});

================================================
FILE: tests/api/image-client.test.ts
================================================
// Mocks
import fetchMock from "jest-fetch-mock";
import LocalStorageMock from "../testdata/localStorageMock";

// Models
import ImageClient from "../../src/api/image-client";

const localStorageMock = new LocalStorageMock();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

beforeEach(() => {    
    
    jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(jest.fn());
});

describe('Testing image client file', () => {

    const client = new ImageClient();

    test('Calling GetImage should return correct data', () => {   
        // Arrange
        fetchMock.mockResponseOnce("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAACgCAMAAABKfUWuAAAAQlBMVEUBIWn5wcoyS4bM0uHsRGMAIGlUapr////kACtDW5DBydtmeaWzvdL7ztaAkLQQLnLw8vYgPHugrMeQnr3f4+zxc4uUeOcyAAAGhklEQVR42u2dXZejIAyGMzKYAVzEj/3/f3UvrK1trQJhbOzmvZgz03OqzlMCCSQpfH/hg5oKklQjIuLPgxARsU67UtUgIv75vl3l+wu46+eHirAUwDV8iCcASEVYBuA6vpMApCEsAfAVvtMApCCkA3yN70QA8xFSAW7hOwHAr28qQhrAbXwnAIhIRUgBuIfvFACpCPMB7uM7AUBjqQhzAcbgUwbg4xHmAfwYfHSEOQA/Ch8VYTrAj8NHQ5gK8CPxURCmAfxYfPkIUwC+HV/X8kMYD5DB6HOO3yiMBcjBeHXT8DPkOIA85r4WsWU3F8YAfDc+PV1bO8SLDRs+CPcBMhh99Th0ANAgNgDQDWPNZxTuAWQx9+kGUbkOEbFzasLIBeE2QC5+XxuWdwxHzISxCLcA8nGbdb28Zw0HKQaheg1QcYo6msWnziw6eQmQVdBW3W5blQS0KzeNo7/fV/39Ewtw+12j3797sTnQL+/sy82BWEivRiBVhWZAF+4vG5wATLFe+3xhWwnA+Dik9n6wV4rWDt7XRgAm+NF6+b8uXxOACeslIoaAiEWX/h/mKhkPI6Jzpd3o/wigR3SgHaIXgFnq0YEG7bAXgFmyblo8nBWAeXMg6ImgzIGZzgwU9V/+P4C/IwEoAAWgABSAAlAACkABKAAFoGyolpATgDSl5X0IwOeNaycAafuujQDMyl1w3WTBlyzW1kWdHMemdiy1lrCxD/D5Xcodl9oRdWbS1NPZnQOo+0IpSM+H+neJRn9SRuDzOy2rihCFGIYeEe0wFsohjME3vgY4ngthhYVzCGPwKbOVYGnsqRAuM7iag/DtpfieC6EqlwQci28/yfxECDtVKvUzHl9MmcPvI9R1gRmrHe6esW8PwRdXaPPrCHt6tsFjDmZ2EmYavthSr19GGELxKSvTjlPxxRcb/ibCuoTXURnj5me03pgKjsCXUu76CwhdX03xKw4AAFVPyH3W+ubINJCTv5CDL63gujxChcq1MCKO0LoRFXEUWkTsEdHCUfhSS/6NKouwQkS01x/UzHGFOIDL2cTIxZfedCIGYcLsfV//Qd+g76eq2PYofDltT8oiXFyMasDQYQ8AWg9pedQUfHmNd0oiLFnCZWZ3sq+Pwpfb+qkYwuWFyKV3N8OtjsKX33ysCMK2x1LxV/asSf0n8tvfxSBMDL8KFsFlAcwxI0oDxn2E2/ysfbiAsta9D2DeLERrAbqHcC90AIBufme3ePENAHPXQWoT2m2EKctwBW8Q3Y2gt0HeQhgRwhpE7PvSNXBpACmObIlG3K8RxsUiPUBfuIQrBSAplCrUCv4Vwoi3DlPtVv8ugDR85b6MYB1hzA7K5QDN2ncApOIr+XUYawhjtvPb2akGESn+yohCqkr4kdTLqKUN3hBaoUDQrZ+gKHnweT/YgBjs4L0Mw3QtmhkFGYVZBKsLwVCBFhx5S8iBzRg/EuA0AgVgrg3XiON4YDvLj9OAQ9sO79mD+AyAFQBANQgJkUgk4qGuEwYUz0p7CStpUkoYkCz4kh4gypRsUFItuECW6H9uwWLDRAsWG6aoOfrLOs7u9tX+zl7XNig7/+HbbZokhY27/eUmgMtXGrT6eJ0Hf4WIob+kR+jmvli+6gNikDVlU1Od0OjamwVPNty5qeOELCn7nt807GpwM0AHdVOqdujjdasTCtcmJyrgW9OWz2jELyTnNQlGvFYyL3SSjPhJYsA0IxYDphmxGDDRiMWAo9Wv8ZPs41h1dn0EWoniYtRuOIKSOrsvN2450qOkju0sH3aviayVpWTDeoeYPrwDJzvmVJmknxsWrItTEvwwnMh6OdrxyMe7994/tXxYjUes9Z5LUMcxc6I1xnnfPHJrvHfGsJn+3FTDe82cMIymFb3su7fcSGB0uKO7MPpuzpzo/MjuqOZpPeaWue0Q0U4H/5bjUc3TimJZPyG7p4M5HgmzazOyi9gXThfDs9b5yaq5gg/ZPaK7AeR31jrvCF47wzPcDfSct8rN9ZO9JngYZk94t+kxGpafrruEePzMpHv2Uzt+AN3sFzpmZrK6ZRlYbVbeuVYTQV6eQmuMu2JkFSNNUncjTsPA7UxOa627W/ITuwS4pyOknqEfYxBRKX7rG8ztlx8IcnNWtUNUbas4uoFm5QizZ/c5ewwdQBcYuoFd9IvvHIFNmCrUg6TB56m5BEeVAMwbgdXjLyKRSCQSiUQikUgkEolEIpFIlKJ/+z5WV3uERkEAAAAASUVORK5CYII=");

        // Act
        const image = client.GetImage('fakeurl');

        // Assert
        expect(JSON.stringify(image)).toMatch(JSON.stringify(`fakeurl`));
    }),

    test('Calling GetImage should return correct data with localstorage', async () => {

        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify('fakeurl'));
        localStorageMock.setItem(`fakeurl`, JSON.stringify({ data: 'fakedata', created: new Date() }));

        // Act
        const image = client.GetImage('fakeurl');

        // Assert
        expect(JSON.stringify(image)).toMatch(JSON.stringify('fakedata'));
    })
});

================================================
FILE: tests/api/restcountry-client.test.ts
================================================
// Mocks
import fetchMock from "jest-fetch-mock";
import LocalStorageMock from "../testdata/localStorageMock";

// Models
import RestCountryClient from "../../src/api/restcountry-client";
import { LocalStorageItem } from "../../src/types/formulaone-card-types";

// Importing test data
import * as countryData from '../testdata/countries.json'

describe('Testing restcountry client file', () => {

    const client = new RestCountryClient();    
    const localStorageMock = new LocalStorageMock();

    Object.defineProperty(window, 'localStorage', { value: localStorageMock });

    test('Calling GetCountriesFromLocalStorage should return correct data', () => {   
         // Arrange
        localStorageMock.clear();     
        localStorageMock.setItem('all', JSON.stringify({ data: JSON.stringify(countryData), created: new Date() } as LocalStorageItem));

        // Act
        const countries = client.GetCountriesFromLocalStorage();

        // Assert
        expect(JSON.stringify(countries)).toMatch(JSON.stringify(countryData));
    }),
    test('Calling GetCountriesFromLocalStorage should return correct data without localstorage', () => {   
        // Arrange
        localStorageMock.clear();     

        // Act
        const countries = client.GetCountriesFromLocalStorage();

        // Assert
        expect(JSON.stringify(countries)).toMatch(JSON.stringify([]));
    }),
    test('Calling GetAll should return correct data', async () => { 
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify(countryData));

        // Act
        const result = await client.GetAll();

        // Assert
        expect(JSON.stringify(result)).toMatch(JSON.stringify(countryData));
    })
})

================================================
FILE: tests/api/weather-client.test.ts
================================================
// Mocks
import { createMock } from "ts-auto-mock";
import fetchMock from "jest-fetch-mock";

// Models
import WeatherClient from "../../src/api/weather-client";
import { WeatherResponse } from "../../src/api/weather-models";

describe('Testing weather client file', () => {

    const client = new WeatherClient('fakekey', 'metric');    
    const weatherData = createMock<WeatherResponse>();

    test('Calling GetWeatherFromLocalStorage should return correct data without localstorage', async () => {   
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify(weatherData));
        
        // Act
        const weather = await client.getWeatherData("1", "2", "2023-01-01");

        // Assert
        expect(JSON.stringify(weather)).toMatch(JSON.stringify(weatherData));
    })
});

================================================
FILE: tests/cards/base-card.test.ts
================================================
// Mocks
import { createMock } from "ts-auto-mock";
import FormulaOneCard from "../../src";

// Models
import WeatherClient from "../../src/api/weather-client";
import { FormulaOneCardConfig } from "../../src/types/formulaone-card-types";

// Importing test data
import ConstructorStandings from "../../src/cards/constructor-standings";

describe('Testing base-card file', () => {
    const parent = createMock<FormulaOneCard>({ 
        config: createMock<FormulaOneCardConfig>()
    });

    test.each`
    key | expected
    ${'constructor'}, ${'Constructor'}
    ${'points'}, ${'Punten'}
    `('Calling translation should return correct translation', ({ key, expected }) => { 
        // Arrange
        parent.config.translations = {  
            "points" : "Punten"
        };

        // Act
        const card = new ConstructorStandings(parent);

        // Assert
        expect(card.translation(key)).toBe(expected);
    }),
    test('Given config without weater_options when weatherOptions is called then default weatherOptions are returned', () => {
        // Arrange
        parent.config.weather_options = {};

        // Act
        const card = new ConstructorStandings(parent);

        // Assert
        expect(card.weatherClient).toMatchObject(new WeatherClient(''));

    }),
    test('Given config without weater_options when weatherOptions is called then default weatherOptions are returned', () => {
        // Arrange
        parent.config.weather_options = { api_key: 'undefined' };

        // Act
        const card = new ConstructorStandings(parent);

        // Assert
        expect(card.weatherClient).toMatchObject(new WeatherClient('undefined'));
    })
});

================================================
FILE: tests/cards/constructor-standings.test.ts
================================================
// Mocks
import { createMock } from 'ts-auto-mock';
import fetchMock from "jest-fetch-mock";
import LocalStorageMock from '../testdata/localStorageMock';

// Models
import ConstructorStandings from '../../src/cards/constructor-standings';
import { getRenderString, getRenderStringAsync } from '../utils';
import { FormulaOneCardConfig } from '../../src/types/formulaone-card-types';
import { Mrdata } from '../../src/api/f1-models';
import { getApiErrorMessage } from '../../src/utils';
import FormulaOneCard from '../../src';
import ImageClient from '../../src/api/image-client';

// Importing test data
import { MRData } from '../testdata/constructorStandings.json'

describe('Testing constructor-standings file', () => {
    const parent = createMock<FormulaOneCard>({ 
        config: createMock<FormulaOneCardConfig>()
    });
    const card = new ConstructorStandings(parent);
    
    const localStorageMock = new LocalStorageMock();
    Object.defineProperty(window, 'localStorage', { value: localStorageMock });

    beforeEach(() => {
        localStorageMock.clear();     
        fetchMock.resetMocks();
        
        jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => null);
        jest.spyOn(ImageClient.prototype, 'GetImage').mockImplementation((url: string) => { return url; });  
    });

    test('Calling render with api returning data', async () => {   
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>MRData }));
        
        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);
        expect(htmlResult).toMatch('<table> <thead> <tr> <th class="width-50">&nbsp;</th> <th>Constructor</th> <th class="width-60 text-center">Pts</th> <th class="text-center">Wins</th> </tr> </thead> <tbody> <tr> <td class="width-50 text-center">1</td> <td>Red Bull</td> <td class="width-60 text-center">576</td> <td class="text-center">13</td> </tr> <tr> <td class="width-50 text-center">2</td> <td>Ferrari</td> <td class="width-60 text-center">439</td> <td class="text-center">4</td> </tr> <tr> <td class="width-50 text-center">3</td> <td>Mercedes</td> <td class="width-60 text-center">373</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">4</td> <td>McLaren</td> <td class="width-60 text-center">129</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">5</td> <td>Alpine F1 Team</td> <td class="width-60 text-center">125</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">6</td> <td>Alfa Romeo</td> <td class="width-60 text-center">52</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">7</td> <td>Aston Martin</td> <td class="width-60 text-center">37</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">8</td> <td>Haas F1 Team</td> <td class="width-60 text-center">34</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">9</td> <td>AlphaTauri</td> <td class="width-60 text-center">34</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">10</td> <td>Williams</td> <td class="width-60 text-center">6</td> <td class="text-center">0</td> </tr> </tbody> </table>');
    }),
    test('Calling render with api and show_teamlogo returning data', async () => {   
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>MRData }));        
        card.config.standings = {
            show_teamlogo: true
        }

        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);        
        expect(htmlResult).toMatch('<table> <thead> <tr> <th class="width-50">&nbsp;</th> <th>Constructor</th> <th class="width-60 text-center">Pts</th> <th class="text-center">Wins</th> </tr> </thead> <tbody> <tr> <td class="width-50 text-center">1</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/red-bull-racing-logo.png.transform/2col-retina/image.png">&nbsp;Red Bull</td> <td class="width-60 text-center">576</td> <td class="text-center">13</td> </tr> <tr> <td class="width-50 text-center">2</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/ferrari-logo.png.transform/2col-retina/image.png">&nbsp;Ferrari</td> <td class="width-60 text-center">439</td> <td class="text-center">4</td> </tr> <tr> <td class="width-50 text-center">3</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/mercedes-logo.png.transform/2col-retina/image.png">&nbsp;Mercedes</td> <td class="width-60 text-center">373</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">4</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/mclaren-logo.png.transform/2col-retina/image.png">&nbsp;McLaren</td> <td class="width-60 text-center">129</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">5</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/alpine-logo.png.transform/2col-retina/image.png">&nbsp;Alpine F1 Team</td> <td class="width-60 text-center">125</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">6</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/alfa-romeo-logo.png.transform/2col-retina/image.png">&nbsp;Alfa Romeo</td> <td class="width-60 text-center">52</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">7</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/aston-martin-logo.png.transform/2col-retina/image.png">&nbsp;Aston Martin</td> <td class="width-60 text-center">37</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">8</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/haas-f1-team-logo.png.transform/2col-retina/image.png">&nbsp;Haas F1 Team</td> <td class="width-60 text-center">34</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">9</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/alphatauri-logo.png.transform/2col-retina/image.png">&nbsp;AlphaTauri</td> <td class="width-60 text-center">34</td> <td class="text-center">0</td> </tr> <tr> <td class="width-50 text-center">10</td> <td><img class="constructor-logo" height="20" width="20" src="https://www.formula1.com/content/dam/fom-website/teams//2024/williams-logo.png.transform/2col-retina/image.png">&nbsp;Williams</td> <td class="width-60 text-center">6</td> <td class="text-center">0</td> </tr> </tbody> </table>');
    }),
    test('Calling render with api not returning data', async () => {   
        // Arrange
        fetchMock.mockRejectOnce(new Error('Error'));
        
        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);
        const expectedResult = getRenderString(getApiErrorMessage('standings'));
        expect(htmlResult).toMatch(expectedResult);
    }),
    test('Calling cardSize with hass and sensor', () => { 
        expect(card.cardSize()).toBe(11);
    })    
});

================================================
FILE: tests/cards/countdown.test.ts
================================================
// Mocks
import { createMock } from "ts-auto-mock";
import fetchMock from "jest-fetch-mock";
import LocalStorageMock from '../testdata/localStorageMock';

// Models
import Countdown from "../../src/cards/countdown";
import { CountdownType, FormulaOneCardConfig } from "../../src/types/formulaone-card-types";
import { getRenderStringAsync } from "../utils";
import { Mrdata, Race } from "../../src/api/f1-models";
import { HTMLTemplateResult } from "lit";
import { HomeAssistant, NumberFormat, TimeFormat } from "custom-card-helpers";
import FormulaOneCard from "../../src";
import * as customCardHelper from "custom-card-helpers";
import RestCountryClient from "../../src/api/restcountry-client";
import { Country } from "../../src/types/rest-country-types";
import ImageClient from "../../src/api/image-client";

// Importing test data
import * as countries from '../testdata/countries.json'
import { MRData as scheduleData } from '../testdata/schedule.json'

describe('Testing countdown file', () => {
        
    const parent = createMock<FormulaOneCard>({ 
        config: createMock<FormulaOneCardConfig>(),
    });
    const hass = createMock<HomeAssistant>();
    hass.locale = {
        language: 'NL', 
        number_format: NumberFormat.comma_decimal,
        time_format: TimeFormat.language
    }
    parent._hass = hass;
    
    const config = createMock<FormulaOneCardConfig>();
    const card = new Countdown(parent);
    const race: Race = {
        season: '2022',
        round: '22',
        url: 'https://en.wikipedia.org/wiki/2022_Formula_One_World_Championship',
        raceName: 'Season is over. See you next year!',
        Circuit: {
            circuitId: 'bahrain',
            url: 'https://en.wikipedia.org/wiki/Bahrain_International_Circuit',
            circuitName: 'Bahrain International Circuit',
            Location: {
                lat: '26.0325',
                long: '50.5106',
                locality: 'Sakhir',
                country: 'Bahrain'
            }
        },
        date: '2022-12-30',
        time: '13:00:00Z',
        FirstPractice: {
            date: '2022-12-30',
            time: '10:00:00Z'
        },
        SecondPractice: {
            date: '2022-12-30',
            time: '14:00:00Z'
        },
        ThirdPractice: {
            date: '2022-12-31',
            time: '11:00:00Z'
        },
        Qualifying:  {
            date: '2022-12-31',
            time: '14:00:00Z'                
        }
    };

    const localStorageMock = new LocalStorageMock();
    Object.defineProperty(window, 'localStorage', { value: localStorageMock });

    beforeEach(() => {
        localStorageMock.clear();     
        fetchMock.resetMocks();
        jest.useFakeTimers();
        
        jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => null);
        jest.spyOn(ImageClient.prototype, 'GetImage').mockImplementation((url: string) => { return url; });  
    });

    beforeAll(() => {
        jest.spyOn(RestCountryClient.prototype, 'GetCountriesFromLocalStorage').mockImplementation(() => {
            return countries as Country[];
        });
    }); 

    afterEach (() => {        
        jest.useRealTimers();  
    });

    test('Calling render with date in future should render countdown', async () => {   
        // Arrange
        card.config.countdown_type = CountdownType.Race;
        jest.setSystemTime(new Date(2022, 2, 1)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const { htmlResult, date } = await getHtmlResultAndDate(card);
        
        // Assert
        expect(htmlResult).toMatch('<table @action=_handleAction .actionHandler= class=""> <tr> <td> <h2 class=""><img height="25" src="https://flagcdn.com/w320/bh.png">&nbsp;&nbsp; 1 : Bahrain Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class=""></h1> </td> </tr> </table>');
        expect(date.value).toMatch('19d 16h 0m 0s');
    }),
    test('Calling render with date equal to race start should render we are racing', async () => {   
        // Arrange
        jest.setSystemTime(new Date(2022, 2, 20, 16)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const { htmlResult, date } = await getHtmlResultAndDate(card);

        // Assert      
        expect(htmlResult).toMatch('<table @action=_handleAction .actionHandler= class=""> <tr> <td> <h2 class=""><img height="25" src="https://flagcdn.com/w320/bh.png">&nbsp;&nbsp; 1 : Bahrain Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class=""></h1> </td> </tr> </table>');
        expect(date.value).toMatch('We are racing!');
    }),
    test('Calling render with date an hour past race start render we are racing', async () => {   
        // Arrange
        jest.setSystemTime(new Date(2022, 2, 20, 17)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const { htmlResult, date } = await getHtmlResultAndDate(card);
        
        // Assert
        expect(htmlResult).toMatch('<table @action=_handleAction .actionHandler= class=""> <tr> <td> <h2 class=""><img height="25" src="https://flagcdn.com/w320/bh.png">&nbsp;&nbsp; 1 : Bahrain Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class=""></h1> </td> </tr> </table>');
        expect(date.value).toMatch('We are racing!');
    }),
    test('Calling render with date an day past race start render countdown till next race', async () => {   
        // Arrange
        card.config.countdown_type = CountdownType.Race;
        jest.setSystemTime(new Date(2022, 2, 21)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const { htmlResult, date } = await getHtmlResultAndDate(card);
        
        // Assert
        expect(htmlResult).toMatch('<table @action=_handleAction .actionHandler= class=""> <tr> <td> <h2 class=""><img height="25" src="https://flagcdn.com/w320/sa.png">&nbsp;&nbsp; 2 : Saudi Arabian Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class=""></h1> </td> </tr> </table>');
        expect(date.value).toMatch('6d 18h 0m 0s');
    }),
    test('Calling render with date end of season', async () => {   
        // Arrange
        jest.setSystemTime(new Date(2022, 11, 30)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);        
        expect(htmlResult).toMatch('<table><tr><td class="text-center"><ha-icon icon="mdi:flag-checkered"></ha-icon><strong>Season is over. See you next year!</strong><ha-icon icon="mdi:flag-checkered"></ha-icon></td></tr></table><table><tr><td class="text-center"><ha-icon icon="mdi:car-speed-limiter"></ha-icon> Loading... <ha-icon icon="mdi:car-speed-limiter"></ha-icon></td></tr></table>');
    }),
    test('Calling render with api not returning data', async () => {   
        // Arrange
        jest.setSystemTime(new Date(2022, 11, 30)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockRejectOnce(new Error('API not available'));

        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);        
        expect(htmlResult).toMatch('<table><tr><td class="text-center"><ha-icon icon="mdi:alert-circle"></ha-icon> Error getting next race <ha-icon icon="mdi:alert-circle"></ha-icon></td></tr></table>');
    }),
    test('Calling renderheader with date end of season and show_raceinfo = true', async () => {   
        // Arrange
        config.show_raceinfo = true;   
        card.config = config; 

        // Act
        const result = card.renderHeader(race, new Date(2022, 11, 30));

        // Assert
        const htmlResult = await getRenderStringAsync(result);
        expect(htmlResult).toMatch('<table><tr><td colspan="5"><h2 class=""><img height="25" src="https://flagcdn.com/w320/bh.png">&nbsp; 22 : Season is over. See you next year!</h2><img width="100%" src="https://www.formula1.com/content/dam/fom-website/2018-redesign-assets/Circuit%20maps%2016x9/Bahrain_Circuit.png.transform/7col/image.png" @action=_handleAction .actionHandler= class="" /></td></tr> </table>');
    }),
    test('Calling renderheader with date end of season and show_raceinfo undefined', async () => {   
        // Arrange
        config.show_raceinfo = undefined;  
        card.config = config;     

        // Act
        const result = card.renderHeader(race, new Date(2022, 11, 30));

        // Assert
        const htmlResult = await getRenderStringAsync(result);        
        expect(htmlResult).toBe('');
    }),
    test.each`
    show_raceinfo | expected
    ${undefined}, ${6}
    ${true}, ${12}
    ${false}, ${6}
    `('Calling getCardSize with type should return card size', ({ show_raceinfo, expected }) => { 
        config.show_raceinfo = show_raceinfo; 
        card.config = config;      

        expect(card.cardSize()).toBe(expected);
    }),
    test('Calling render with actions', async () => {   
        // Arrange
        const spy = jest.spyOn(customCardHelper, 'handleAction');

        card.config.f1_font = true;
        card.config.countdown_type = undefined;
        card.config.actions = {
            tap_action: {
                action: 'navigate',
                navigation_path: '/lovelace/0',
            },
            hold_action: {
                action: 'navigate',
                navigation_path: '/lovelace/1',
            },
            double_tap_action: {
                action: 'navigate',
                navigation_path: '/lovelace/2',
            }
        }

        jest.setSystemTime(new Date(2022, 2, 1)); // Weird bug in jest setting this to the last of the month
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));

        // Act
        const { htmlResult, date, handleAction } = await getHtmlResultAndDate(card);
        
        // Assert
        expect(htmlResult).toMatch('<table @action=_handleAction .actionHandler= class="clickable"> <tr> <td> <h2 class="formulaone-font"><img height="25" src="https://flagcdn.com/w320/bh.png">&nbsp;&nbsp; 1 : Bahrain Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class="formulaone-font"></h1> </td> </tr> </table>');
        expect(date.value).toMatch('We are racing!');
        
        // eslint-disable-next-line @typescript-eslint/ban-types
        handleAction({ detail: { action: 'tap' } });
        handleAction({ detail: { action: 'double_tap' } });
        handleAction({ detail: { action: 'hold' } });
        
        expect(customCardHelper.handleAction).toBeCalledTimes(3);

        spy.mockClear();
    }),    
    test.each`
    countdown_type | current_date | expected   
    ${CountdownType.Practice1}, ${new Date(2022, 3, 19)}, ${new Date("2022-04-22T11:30:00.000Z")}
    ${CountdownType.Practice2}, ${new Date(2022, 3, 19)}, ${new Date("2022-04-23T10:30:00.000Z")}
    ${CountdownType.Practice3}, ${new Date(2022, 2, 1)}, ${new Date("2022-03-19T12:00:00.000Z")}
    ${CountdownType.Qualifying}, ${new Date(2022, 3, 19)}, ${new Date("2022-04-22T15:00:00.000Z")}    
    ${CountdownType.Race}, ${new Date(2022, 3, 19)}, ${new Date("2022-04-24T13:00:00.000Z")}  
    ${CountdownType.Sprint}, ${new Date(2022, 3, 19, 11, 0)}, ${new Date("2022-04-23T14:30:00.000Z")}
    `(`Calling render with countdown_type $countdown_type`, async ({ countdown_type, current_date, expected }) => {
        // Arrange
        config.countdown_type = countdown_type; 
        card.config = config;             
        jest.setSystemTime(current_date); // Weird bug in jest setting this to the last of the month

        // Act
        const result = card.getNextEvent(scheduleData.RaceTable.Races);
        
        // Assert       
        expect(result.raceDateTime).toMatchObject(expected);
    }), 
    test.each`
    current_date | expected | hasSprint
    ${new Date(2022, 3, 22, 13, 0)}, ${new Date("2022-04-22T11:30:00.000Z")}, ${true} // Practice 1
    ${new Date(2022, 3, 22, 16, 30)}, ${new Date("2022-04-22T15:00:00.000Z")}, ${true} // Qualifying
    ${new Date(2022, 3, 23, 12, 0)}, ${new Date("2022-04-23T10:30:00.000Z")}, ${true} // Practice 2
    ${new Date(2022, 3, 23, 14, 20)}, ${new Date("2022-04-23T14:30:00.000Z")}, ${true} // Sprint
    ${new Date(2022, 3, 24, 10)}, ${new Date("2022-04-24T13:00:00.000Z")}, ${true} // Race
    ${new Date(2022, 3, 23, 14, 20)}, ${new Date("2022-04-24T13:00:00.000Z")}, ${false} // Race
    `(`Calling render with race, events in the future $current_date`, async ({ current_date, expected, hasSprint }) => {
        // Arrange      
        jest.setSystemTime(current_date); // Weird bug in jest setting this to the last of the month  

        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));
        config.countdown_type = [ CountdownType.Practice1, CountdownType.Practice2, CountdownType.Practice3, CountdownType.Qualifying, CountdownType.Sprint, CountdownType.Race ];
        card.config = config;

        const races = [{
            season: '2022',
            round: '1',
            url: 'https://en.wikipedia.org/wiki/2022_Bahrain_Grand_Prix',
            raceName: 'Bahrain Grand Prix',
            Circuit: {
                circuitId: 'bahrain',
                url: 'http://en.wikipedia.org/wiki/Bahrain_International_Circuit',
                circuitName: 'Bahrain International Circuit',
                Location: {
                    lat: '26.0325',
                    long: '50.5106',
                    locality: 'Sakhir',
                    country: 'Bahrain'
                }
            },
            date: '2022-04-24',
            time: '13:00:00Z',
            FirstPractice: { date: '2022-04-22', time: '11:30:00Z' },
            SecondPractice: { date: '2022-04-23', time: '10:30:00Z' },
            Qualifying: { date: '2022-04-22', time: '15:00:00Z' },
            Sprint: hasSprint ? { date: '2022-04-23', time: '14:30:00Z' } : undefined
        } as Race];

        // Act
        const result = card.getNextEvent(races);

        // Assert
        expect(result.raceDateTime).toMatchObject(expected);
    }),
    test.each`
    current_date | expected | withFont
    ${new Date(2022, 3, 22, 13, 0)}, ${"0d 0h 30m 0s"}, ${true} // Practice 1
    ${new Date(2022, 3, 22, 16, 30)}, ${"0d 0h 30m 0s"}, ${true} // Qualifying
    ${new Date(2022, 3, 23, 12, 0)}, ${"0d 0h 30m 0s"}, ${true} // Practice 2
    ${new Date(2022, 3, 23, 14, 20)}, ${"0d 2h 10m 0s"}, ${true} // Sprint
    ${new Date(2022, 3, 24, 10)}, ${"0d 5h 0m 0s"}, ${true} // Race
    ${new Date(2022, 3, 23, 14, 20)}, ${"0d 2h 10m 0s"},${false}// Race
    `(`Calling render with race, events in the future $current_date`, async ({ current_date, expected, withFont }) => {
        // Arrange      
        jest.setSystemTime(current_date); // Weird bug in jest setting this to the last of the month  

        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>scheduleData }));
        config.countdown_type = [ CountdownType.Practice1, CountdownType.Practice2, CountdownType.Practice3, CountdownType.Qualifying, CountdownType.Sprint, CountdownType.Race ];
        config.f1_font = withFont;
        card.config = config;
        
        // Act
        const { htmlResult, date } = await getHtmlResultAndDate(card);

        // Assert
        expect(htmlResult).toMatch(`<table @action=_handleAction .actionHandler= class="clickable"> <tr> <td> <h2 class="${withFont ? 'formulaone-font' : ''}"><img height="25" src="https://flagcdn.com/w320/it.png">&nbsp;&nbsp; 4 : Emilia Romagna Grand Prix</h2> </td> </tr> <tr> <td class="text-center"> <h1 class="${withFont ? 'formulaone-font' : ''}"></h1> </td> </tr> </table>`);
        expect(date.value).toMatch(expected);
    }),
    test('Calling constructor without countdown_type should set countdown_type',  () => {   
        // Arrange
        const config = createMock<FormulaOneCardConfig>();
        config.countdown_type = undefined;
        const parent = createMock<FormulaOneCard>();
        parent.config = config;

        // Act
        const card = new Countdown(parent);

        // Assert
        expect(card.config.countdown_type).toBe(CountdownType.Race);
    }),
    test('Calling constructor with countdown_type should not change countdown_type',  () => {   
        // Arrange
        const config = createMock<FormulaOneCardConfig>();
        config.countdown_type = CountdownType.Practice1;
        const parent = createMock<FormulaOneCard>();
        parent.config = config;

        // Act
        const card = new Countdown(parent);

        // Assert
        expect(card.config.countdown_type).toBe(CountdownType.Practice1);
    });
});

async function getHtmlResultAndDate(card: Countdown) {
    const result = card.render();

    const promise = (result.values[0] as HTMLTemplateResult).values[0] as Promise<HTMLTemplateResult>;
    const promiseResult = await promise;

    const iterator = (promiseResult.values[8] as HTMLTemplateResult).values[0] as AsyncIterableIterator<HTMLTemplateResult>;
    // eslint-disable-next-line @typescript-eslint/ban-types
    const handleAction = promiseResult.values[0] as Function;

    const date = await iterator.next();
    
    const htmlResult = await getRenderStringAsync(promiseResult);
    return { htmlResult, date, handleAction };
}


================================================
FILE: tests/cards/driver-standings.test.ts
================================================
// Mocks
import { createMock } from "ts-auto-mock";
import fetchMock from "jest-fetch-mock";
import LocalStorageMock from '../testdata/localStorageMock';

// Models
import DriverStandings from '../../src/cards/driver-standings';
import { getRenderString, getRenderStringAsync } from '../utils';
import { MRData } from '../testdata/driverStandings.json'
import { FormulaOneCardConfig } from '../../src/types/formulaone-card-types';
import { Mrdata } from '../../src/api/f1-models';
import { getApiErrorMessage } from '../../src/utils';
import FormulaOneCard from '../../src';
import RestCountryClient from '../../src/api/restcountry-client';
import { Country } from '../../src/types/rest-country-types';

// Importing test data
import * as countries from '../testdata/countries.json'
import ImageClient from "../../src/api/image-client";

beforeEach(() => {    
    jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => null);
    jest.spyOn(ImageClient.prototype, 'GetImage').mockImplementation((url: string) => { return url; });
});

describe('Testing driver-standings file', () => {
    const parent = createMock<FormulaOneCard>({ 
        config: createMock<FormulaOneCardConfig>()
    });

    const card = new DriverStandings(parent);

    const localStorageMock = new LocalStorageMock();
    Object.defineProperty(window, 'localStorage', { value: localStorageMock });

    beforeEach(() => {
        localStorageMock.clear();     
        fetchMock.resetMocks();
    });

    beforeAll(() => {
        jest.spyOn(RestCountryClient.prototype, 'GetCountriesFromLocalStorage').mockImplementation(() => {
            return countries as Country[];
        });
    });

    test('Calling render with api returning data', async () => {   
        // Arrange
        fetchMock.mockResponseOnce(JSON.stringify({ MRData : <Mrdata>MRData }));
        
        // Act
        const result = card.render();

        // Assert
        const htmlResult = await getRenderStringAsync(result);
        expect(htmlResult).toMatch('<table> <thead> <tr> <th class="width-50" colspan="2">&nbsp;</th> <th>Driver</th> <th class="width-60 text-center">Pts</th> <th class="text-center">Wins</th> </tr> </thead> <tbody> <tr> <td class="width-40 text-center">1</td> <td>VER</td> <td>Max Verstappen</td> <td class="width-60 text-center">341</td> <td class="text-center">11</td> </tr> <tr> <td class="width-40 text-center">2</td> <td>LEC</td> <td>Charles Leclerc</td> <td class="width-60 text-center">237</td> <td class="text-center">3</td> </tr> <tr> <td class="width-40 text-center">3</td> <td>PER</td> <td>Sergio Pérez</td> <td class="width-60 text-center">235</td> <td class="text-center">2</td> </tr> <tr> <td class="width-40 text-center">4</td> <td>RUS</td> <td>George Russell</td> <td class="width-60 text-center">203</td> <td class="text-center">0</td> </tr> <tr> <td class="width-40 text-center">5</td> <td>SAI</td> <td>Carlos Sainz</td> <td class="width-60 text-center">202</td> <td class="text-center">1</td> </tr> <tr> <td class="width-40 text-center">6</td> <td>HAM</td> <td>Lewis Hamilton</td> <td class="width-60 text-center">170</td> <td class="text-center">0</td> </tr> <tr> <td class="width-40 text-center">7</td> <td>NOR</td> <td>Lando Norris</td> <td class="width-60 text-center">100</t
Download .txt
gitextract_77tvhghf/

├── .devcontainer/
│   └── devcontainer.json
├── .eslintrc.js
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   └── workflows/
│       ├── pr.yml
│       └── push.yml
├── .gitignore
├── .nvmrc
├── LICENSE
├── README.md
├── formulaone-card.js.LICENSE.txt
├── hacs.json
├── jest.config.js
├── package.json
├── src/
│   ├── api/
│   │   ├── client-base.ts
│   │   ├── ergast-client.ts
│   │   ├── f1-models.ts
│   │   ├── f1sensor-client.ts
│   │   ├── image-client.ts
│   │   ├── restcountry-client.ts
│   │   ├── vc-weather-client.ts
│   │   └── weather-models.ts
│   ├── cards/
│   │   ├── base-card.ts
│   │   ├── constructor-standings.ts
│   │   ├── countdown.ts
│   │   ├── driver-standings.ts
│   │   ├── last-result.ts
│   │   ├── next-race.ts
│   │   ├── results.ts
│   │   └── schedule.ts
│   ├── consts.ts
│   ├── directives/
│   │   └── action-handler-directive.ts
│   ├── editor.ts
│   ├── fonts.ts
│   ├── index.ts
│   ├── lib/
│   │   ├── constants.ts
│   │   ├── format_date.ts
│   │   ├── format_date_time.ts
│   │   ├── format_time.ts
│   │   └── use_am_pm.ts
│   ├── styles.ts
│   ├── types/
│   │   ├── formulaone-card-types.ts
│   │   └── rest-country-types.ts
│   └── utils.ts
├── test/
│   ├── configuration.yaml
│   └── lovelace.yaml
├── tests/
│   ├── api/
│   │   ├── ergast-client.test.ts
│   │   ├── image-client.test.ts
│   │   ├── restcountry-client.test.ts
│   │   └── weather-client.test.ts
│   ├── cards/
│   │   ├── base-card.test.ts
│   │   ├── constructor-standings.test.ts
│   │   ├── countdown.test.ts
│   │   ├── driver-standings.test.ts
│   │   ├── last-result.test.ts
│   │   ├── next-race.test.ts
│   │   ├── results.test.ts
│   │   └── schedule.test.ts
│   ├── config.ts
│   ├── index.test.ts
│   ├── lib/
│   │   ├── formate_date.test.ts
│   │   ├── formate_date_time.test.ts
│   │   └── formate_time.test.ts
│   ├── testdata/
│   │   ├── constructorStandings.json
│   │   ├── countries.json
│   │   ├── driverStandings.json
│   │   ├── localStorageMock.ts
│   │   ├── qualifying.json
│   │   ├── results.json
│   │   ├── schedule.json
│   │   ├── seasons.json
│   │   └── sprint.json
│   ├── utils/
│   │   ├── calculateWindDirection.test.ts
│   │   ├── checkConfig.test.ts
│   │   ├── getCircuitName.test.ts
│   │   ├── getCountryFlagUrl.test.ts
│   │   ├── getDriverName.test.ts
│   │   ├── getRefreshTime.test.ts
│   │   ├── getTeamImageUrl.test.ts
│   │   ├── hasConfigOrEntitiesChanged.test.ts
│   │   ├── reduceArray.test.ts
│   │   ├── renderHeader.test.ts
│   │   ├── renderLastYearsResults.test.ts
│   │   ├── renderRaceInfo.test.ts
│   │   └── renderWeatherInfo.test.ts
│   └── utils.ts
├── tsconfig.json
└── webpack.config.js
Download .txt
SYMBOL INDEX (193 symbols across 26 files)

FILE: src/api/client-base.ts
  type IClient (line 4) | interface IClient {
  method GetData (line 23) | async GetData<T>(endpoint: string, cacheResult: boolean, hoursBeforeInva...

FILE: src/api/ergast-client.ts
  class ErgastClient (line 5) | class ErgastClient extends ClientBase implements IClient {
    method GetSchedule (line 9) | async GetSchedule(season: number) : Promise<Race[]> {
    method GetLastResult (line 15) | async GetLastResult() : Promise<Race> {
    method GetDriverStandings (line 22) | async GetDriverStandings() : Promise<DriverStanding[]> {
    method GetDriverStandingsForSeason (line 30) | async GetDriverStandingsForSeason(selectedSeason: number | undefined) {
    method GetConstructorStandings (line 42) | async GetConstructorStandings() : Promise<ConstructorStanding[]> {
    method GetConstructorStandingsForSeason (line 50) | async GetConstructorStandingsForSeason(selectedSeason: number | undefi...
    method GetSprintResults (line 62) | async GetSprintResults(season: number, round: number) : Promise<RaceTa...
    method GetQualifyingResults (line 68) | async GetQualifyingResults(season: number, round: number) : Promise<Ra...
    method GetResults (line 74) | async GetResults(season: number, round: number) : Promise<RaceTable> {
    method GetSeasons (line 80) | async GetSeasons() : Promise<Season[]> {
    method GetSeasonRaces (line 86) | async GetSeasonRaces(season: number) : Promise<Race[]> {
    method GetLastYearsResults (line 92) | async GetLastYearsResults(circuitName: string) : Promise<Race> {
    method RefreshCache (line 109) | async RefreshCache() {

FILE: src/api/f1-models.ts
  type Root (line 1) | interface Root {
  type Mrdata (line 5) | interface Mrdata {
  type StandingsTable (line 17) | interface StandingsTable {
  type StandingsList (line 22) | interface StandingsList {
  type DriverStanding (line 29) | interface DriverStanding {
  type ConstructorStanding (line 38) | interface ConstructorStanding {
  type RaceTable (line 46) | interface RaceTable {
  type Race (line 52) | interface Race {
  type Circuit (line 71) | interface Circuit {
  type Location (line 78) | interface Location {
  type Result (line 85) | interface Result {
  type QualifyingResult (line 99) | interface QualifyingResult {
  type Driver (line 109) | interface Driver {
  type Constructor (line 120) | interface Constructor {
  type Time (line 127) | interface Time {
  type FastestLap (line 132) | interface FastestLap {
  type Time2 (line 139) | interface Time2 {
  type AverageSpeed (line 143) | interface AverageSpeed {
  type SeasonTable (line 147) | interface SeasonTable {
  type Season (line 151) | interface Season {
  type FirstPractice (line 156) | interface FirstPractice {
  type SecondPractice (line 161) | interface SecondPractice {
  type ThirdPractice (line 166) | interface ThirdPractice {
  type Qualifying (line 171) | interface Qualifying {
  type Sprint (line 176) | interface Sprint {

FILE: src/api/f1sensor-client.ts
  class F1SensorClient (line 7) | class F1SensorClient extends ClientBase implements IClient, IWeatherClie...
    method constructor (line 12) | constructor(hass: HomeAssistant, entity: string) {
    method getRaceWeatherData (line 20) | async getRaceWeatherData(options: WeatherOptions, race: Race) : Promis...
    method GetConstructorStandings (line 29) | async GetConstructorStandings() : Promise<ConstructorStanding[]> {
    method GetConstructorStandingsForSeason (line 33) | async GetConstructorStandingsForSeason(season: number | undefined) : P...
    method GetDriverStandings (line 41) | async GetDriverStandings(): Promise<DriverStanding[]> {
    method GetDriverStandingsForSeason (line 45) | async GetDriverStandingsForSeason(season: number | undefined): Promise...
    method GetSchedule (line 52) | async GetSchedule(season: number): Promise<Race[]> {
    method GetLastResult (line 61) | GetLastResult(): Promise<Race> {
    method GetSprintResults (line 65) | GetSprintResults(season: number, round: number): Promise<RaceTable> {
    method GetQualifyingResults (line 69) | GetQualifyingResults(season: number, round: number): Promise<RaceTable> {
    method GetResults (line 73) | GetResults(season: number, round: number): Promise<RaceTable> {
    method GetSeasons (line 76) | GetSeasons(): Promise<Season[]> {
    method GetSeasonRaces (line 80) | GetSeasonRaces(season: number): Promise<Race[]> {
    method RefreshCache (line 83) | RefreshCache(): void {

FILE: src/api/image-client.ts
  class ImageClient (line 6) | class ImageClient {
    method GetImage (line 9) | GetImage(url: string): string {
    method GetTeamLogoImage (line 47) | GetTeamLogoImage(teamName: string, selectedSeason: number): string {
    method GetTrackLayoutImage (line 73) | GetTrackLayoutImage(race: Race): string {

FILE: src/api/restcountry-client.ts
  class RestCountryClient (line 5) | class RestCountryClient extends ClientBase {
    method GetAll (line 10) | async GetAll() : Promise<Country[]> {
    method GetCountriesFromLocalStorage (line 14) | GetCountriesFromLocalStorage() : Country[] {

FILE: src/api/vc-weather-client.ts
  class VCWeatherClient (line 6) | class VCWeatherClient
    method constructor (line 15) | constructor(apiKey: string, unitGroup?: string) {
    method getRaceWeatherData (line 21) | async getRaceWeatherData(options: WeatherOptions, race: Race): Promise...

FILE: src/api/weather-models.ts
  type IWeatherClient (line 4) | interface IWeatherClient {
  type WeatherData (line 8) | interface WeatherData {
  type Hour (line 30) | interface Hour {
  type Day (line 63) | interface Day {
  type CurrentConditions (line 103) | interface CurrentConditions {
  type WeatherResponse (line 136) | interface WeatherResponse {

FILE: src/cards/base-card.ts
  method constructor (line 22) | constructor(parent: FormulaOneCard) {
  method translation (line 32) | translation(key: string) : string {
  method getProperties (line 47) | protected getProperties() {
  method getParentCardValues (line 56) | protected getParentCardValues() {

FILE: src/cards/constructor-standings.ts
  class ConstructorStandings (line 8) | class ConstructorStandings extends BaseCard {
    method constructor (line 17) | constructor(parent: FormulaOneCard) {
    method cardSize (line 21) | cardSize(): number {
    method renderStandingRow (line 25) | renderStandingRow(standing: ConstructorStanding, selectedSeason: numbe...
    method render (line 38) | render() : HTMLTemplateResult {
    method setSeason (line 88) | setSeason(ev: any) {

FILE: src/cards/countdown.ts
  class Countdown (line 12) | class Countdown extends BaseCard {
    method constructor (line 38) | constructor(parent: FormulaOneCard) {
    method cardSize (line 44) | cardSize(): number {
    method renderHeader (line 48) | renderHeader(race: Race): HTMLTemplateResult {
    method countDownTillDate (line 55) | async *countDownTillDate(raceDateTime: Date) {
    method render (line 92) | render() : HTMLTemplateResult {
    method getNextEvent (line 145) | getNextEvent(response: Race[]) {

FILE: src/cards/driver-standings.ts
  class DriverStandings (line 8) | class DriverStandings extends BaseCard {
    method constructor (line 18) | constructor(parent: FormulaOneCard) {
    method cardSize (line 22) | cardSize(): number {
    method renderStandingRow (line 26) | renderStandingRow(standing: DriverStanding, selectedSeason: number): H...
    method render (line 38) | render() : HTMLTemplateResult {
    method setSeason (line 89) | setSeason(ev: any) {

FILE: src/cards/last-result.ts
  class LastResult (line 8) | class LastResult extends BaseCard {
    method constructor (line 17) | constructor(parent: FormulaOneCard) {
    method cardSize (line 21) | cardSize(): number {
    method renderResultRow (line 25) | renderResultRow(result: Result): HTMLTemplateResult {
    method render (line 37) | render() : HTMLTemplateResult {

FILE: src/cards/next-race.ts
  class NextRace (line 10) | class NextRace extends BaseCard {
    method cardSize (line 30) | cardSize(): number {
    method render (line 34) | render() : HTMLTemplateResult {
    method renderDateTime (line 75) | private renderDateTime(nextRace: Race) {

FILE: src/cards/results.ts
  class Results (line 9) | class Results extends BaseCard {
    method constructor (line 37) | constructor(parent: FormulaOneCard) {
    method cardSize (line 44) | cardSize(): number {
    method renderTabs (line 48) | renderTabs(selectedRace: Race) : FormulaOneCardTab[] {
    method renderSprint (line 70) | renderSprint(selectedRace: Race) : HTMLTemplateResult {
    method renderQualifying (line 90) | renderQualifying(selectedRace: Race): HTMLTemplateResult {
    method renderResults (line 110) | renderResults(selectedRace: Race): HTMLTemplateResult {
    method renderResultRow (line 137) | renderResultRow(result: Result, fastest: boolean, selectedSeason: stri...
    method renderQualifyingResultRow (line 149) | renderQualifyingResultRow(result: QualifyingResult, selectedSeason: st...
    method renderHeader (line 161) | renderHeader(race?: Race): HTMLTemplateResult {
    method render (line 170) | render() : HTMLTemplateResult {
    method setSelectedRace (line 257) | setSelectedRace(ev: SelectChangeEvent) {
    method setRaces (line 290) | private setRaces(ev: SelectChangeEvent) {
    method getUpcomingRace (line 304) | private getUpcomingRace(now: Date, races: Race[]) : Race {
    method getLastResult (line 322) | private getLastResult() {
    method setSelectedTabIndex (line 369) | setSelectedTabIndex(index: number) {
    method icon (line 376) | icon(key: string) : string {
    method tabOrder (line 385) | tabOrder(tab: string) : number {

FILE: src/cards/schedule.ts
  class Schedule (line 11) | class Schedule extends BaseCard {
    method constructor (line 21) | constructor(parent: FormulaOneCard) {
    method cardSize (line 25) | cardSize(): number {
    method renderLocation (line 29) | renderLocation(circuit: Circuit) {
    method renderScheduleRow (line 34) | renderScheduleRow(race: Race): HTMLTemplateResult {
    method render (line 48) | render() : HTMLTemplateResult {

FILE: src/consts.ts
  constant CARD_NAME (line 1) | const CARD_NAME = 'formulaone-card';
  constant CARD_EDITOR_NAME (line 2) | const CARD_EDITOR_NAME = `${CARD_NAME}-editor`;

FILE: src/directives/action-handler-directive.ts
  type HASSDomEvents (line 11) | interface HASSDomEvents {
  class ActionHandler (line 16) | class ActionHandler extends HTMLElement implements ActionHandler {
    method constructor (line 28) | constructor() {
    method connectedCallback (line 33) | public connectedCallback(): void {
    method bind (line 59) | public bind(element: ActionHandlerElement, options: ActionHandlerOptio...
    method startAnimation (line 140) | private startAnimation(x: number, y: number): void {
    method stopAnimation (line 151) | private stopAnimation(): void {
  method update (line 183) | update(part: AttributePart, [options]: DirectiveParameters<this>) {
  method render (line 189) | render(_options?: ActionHandlerOptions) {}

FILE: src/editor.ts
  class FormulaOneCardEditor (line 11) | class FormulaOneCardEditor extends EditorForm {
    method render (line 13) | protected render(): TemplateResult {
    method styles (line 96) | static get styles() : CSSResult {

FILE: src/index.ts
  class FormulaOneCard (line 37) | class FormulaOneCard extends LitElement {
    method properties (line 42) | set properties(values: Map<string, unknown>) {
    method properties (line 46) | get properties() {
    method constructor (line 50) | constructor() {
    method getConfigElement (line 59) | public static async getConfigElement(): Promise<LovelaceCardEditor> {
    method setConfig (line 64) | setConfig(config: FormulaOneCardConfig) {
    method setCountryCache (line 71) | setCountryCache() {
    method shouldUpdate (line 78) | protected shouldUpdate(changedProps: PropertyValues): boolean {
    method hass (line 82) | set hass(hass: HomeAssistant) {
    method styles (line 112) | static get styles(): CSSResult {
    method render (line 117) | render() : HTMLTemplateResult {
    method getCardSize (line 134) | getCardSize() {
    method renderRefreshButton (line 139) | renderRefreshButton() {
    method refreshCache (line 144) | refreshCache(event: Event) {

FILE: src/lib/constants.ts
  constant TIMESTAMP_FORMATS (line 9) | const TIMESTAMP_FORMATS = ['relative', 'total', 'date', 'time', 'datetim...
  constant SECONDARY_INFO_VALUES (line 11) | const SECONDARY_INFO_VALUES = [

FILE: src/types/formulaone-card-types.ts
  type FormulaOneCardConfig (line 4) | interface FormulaOneCardConfig extends LovelaceCardConfig {
  type F1DataSource (line 39) | enum F1DataSource {
  type ValueChangedEvent (line 44) | interface ValueChangedEvent {
  type WeatherOptions (line 60) | interface WeatherOptions {
  type WeatherSource (line 73) | enum WeatherSource {
  type NextRaceDisplay (line 78) | enum NextRaceDisplay {
  type WeatherUnit (line 84) | enum WeatherUnit {
  type CountdownType (line 90) | enum CountdownType {
  type ActionOptions (line 100) | interface ActionOptions {
  type Translation (line 106) | interface Translation {
  type CustomIcons (line 110) | interface CustomIcons {
  type StandingDisplayOptions (line 114) | interface StandingDisplayOptions {
  type PreviousRaceDisplay (line 121) | enum PreviousRaceDisplay {
  type FormulaOneCardType (line 127) | enum FormulaOneCardType {
  type LocalStorageItem (line 137) | interface LocalStorageItem {
  type CardProperties (line 142) | interface CardProperties {
  type ActionHandler (line 146) | interface ActionHandler extends HTMLElement {
  type ActionHandlerElement (line 151) | interface ActionHandlerElement extends HTMLElement {
  type FormulaOneCardTab (line 155) | interface FormulaOneCardTab {
  type SelectChangeEvent (line 163) | interface SelectChangeEvent {
  type mwcTabBarEvent (line 169) | interface mwcTabBarEvent extends Event {

FILE: src/types/rest-country-types.ts
  type Flags (line 1) | interface Flags {
  type Currency (line 6) | interface Currency {
  type Language (line 12) | interface Language {
  type Translations (line 19) | interface Translations {
  type RegionalBloc (line 33) | interface RegionalBloc {
  type Country (line 40) | interface Country {

FILE: tests/cards/countdown.test.ts
  function getHtmlResultAndDate (line 363) | async function getHtmlResultAndDate(card: Countdown) {

FILE: tests/cards/results.test.ts
  function setFetchMock (line 555) | function setFetchMock() {

FILE: tests/testdata/localStorageMock.ts
  class LocalStorageMock (line 1) | class LocalStorageMock {
    method constructor (line 4) | constructor() {
    method clear (line 8) | clear() {
    method getItem (line 12) | getItem(key: string): string {
    method setItem (line 16) | setItem(key: string, value: string) {
    method removeItem (line 20) | removeItem(key: string) {
Condensed preview — 89 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (919K chars).
[
  {
    "path": ".devcontainer/devcontainer.json",
    "chars": 1635,
    "preview": "{\n  \"name\": \"formulaone-card Dev\",\n  \"image\": \"marcokreeft/hass-custom-devcontainer\",\n  \"postCreateCommand\": \"sudo -E co"
  },
  {
    "path": ".eslintrc.js",
    "chars": 422,
    "preview": "module.exports = {\n    \"env\": {\n        \"browser\": true,\n        \"commonjs\": true,\n        \"es2021\": true\n    },\n    \"ex"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 835,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 28,
    "preview": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 763,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is"
  },
  {
    "path": ".github/workflows/pr.yml",
    "chars": 884,
    "preview": "name: Tests\n\n# Controls when the workflow will run\non:\n  # Triggers the workflow on push or pull request events but only"
  },
  {
    "path": ".github/workflows/push.yml",
    "chars": 2419,
    "preview": "name: Tests\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - '**'\n      - '!README.md'\n  workflow_dispatch:\n\nj"
  },
  {
    "path": ".gitignore",
    "chars": 67,
    "preview": "node_modules/*\ncoverage/*\nformulaone-card.js\nformulaone-card.js.gz\n"
  },
  {
    "path": ".nvmrc",
    "chars": 4,
    "preview": "v18\n"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2022 Marco Kreeft\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 17406,
    "preview": "# FormulaOne Card\n\n[![GH-release](https://img.shields.io/github/v/release/marcokreeft87/formulaone-card.svg?style=flat-s"
  },
  {
    "path": "formulaone-card.js.LICENSE.txt",
    "chars": 454,
    "preview": "/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\n\n/**\n * @license\n * Copyright "
  },
  {
    "path": "hacs.json",
    "chars": 133,
    "preview": "{\n    \"name\": \"Formula One Card\",\n    \"filename\": \"formulaone-card.js\",\n    \"render_readme\": true,\n    \"content_in_root\""
  },
  {
    "path": "jest.config.js",
    "chars": 784,
    "preview": "module.exports = {\n    transform: {\n        '^.+\\\\.ts?$': ['ts-jest', { \"compiler\": \"ttypescript\" } ],\n        '^.+\\\\.(j"
  },
  {
    "path": "package.json",
    "chars": 1890,
    "preview": "{\n  \"name\": \"formulaone-card\",\n  \"version\": \"1.14.6\",\n  \"description\": \"Frontend card for Home Assistant to display Form"
  },
  {
    "path": "src/api/client-base.ts",
    "chars": 1634,
    "preview": "import { LocalStorageItem } from \"../types/formulaone-card-types\";\nimport { ConstructorStanding, DriverStanding, Race } "
  },
  {
    "path": "src/api/ergast-client.ts",
    "chars": 4818,
    "preview": "import { getRefreshTime } from '../utils';\nimport { ClientBase, IClient } from './client-base';\nimport { ConstructorStan"
  },
  {
    "path": "src/api/f1-models.ts",
    "chars": 3255,
    "preview": "export interface Root {\n    MRData: Mrdata\n  }\n  \n  export interface Mrdata {\n    xmlns: string\n    series: string\n    u"
  },
  {
    "path": "src/api/f1sensor-client.ts",
    "chars": 3559,
    "preview": "import { HomeAssistant } from 'custom-card-helpers';\nimport { ClientBase, IClient } from './client-base';\nimport { Const"
  },
  {
    "path": "src/api/image-client.ts",
    "chars": 3289,
    "preview": "import { ImageConstants } from \"../lib/constants\";\nimport { LocalStorageItem } from \"../types/formulaone-card-types\";\nim"
  },
  {
    "path": "src/api/restcountry-client.ts",
    "chars": 835,
    "preview": "import { LocalStorageItem } from \"../types/formulaone-card-types\";\nimport { Country } from \"../types/rest-country-types\""
  },
  {
    "path": "src/api/vc-weather-client.ts",
    "chars": 3322,
    "preview": "import { WeatherOptions, WeatherUnit } from '../types/formulaone-card-types';\nimport { ClientBase } from './client-base'"
  },
  {
    "path": "src/api/weather-models.ts",
    "chars": 3434,
    "preview": "import { WeatherOptions } from \"../types/formulaone-card-types\";\nimport { Race } from \"./f1-models\";\n\nexport interface I"
  },
  {
    "path": "src/cards/base-card.ts",
    "chars": 2675,
    "preview": "import { HomeAssistant } from \"custom-card-helpers\";\nimport { HTMLTemplateResult } from \"lit-html\";\nimport FormulaOneCar"
  },
  {
    "path": "src/cards/constructor-standings.ts",
    "chars": 4361,
    "preview": "import { html, HTMLTemplateResult } from \"lit-html\";\nimport { until } from 'lit-html/directives/until.js';\nimport Formul"
  },
  {
    "path": "src/cards/countdown.ts",
    "chars": 8516,
    "preview": "import { ActionHandlerEvent, formatDateTime, hasAction, HomeAssistant } from \"custom-card-helpers\";\nimport { html, HTMLT"
  },
  {
    "path": "src/cards/driver-standings.ts",
    "chars": 4728,
    "preview": "import { html, HTMLTemplateResult } from \"lit-html\";\nimport { until } from 'lit-html/directives/until.js';\nimport Formul"
  },
  {
    "path": "src/cards/last-result.ts",
    "chars": 2602,
    "preview": "import { html, HTMLTemplateResult } from \"lit-html\";\nimport { until } from 'lit-html/directives/until.js';\nimport Formul"
  },
  {
    "path": "src/cards/next-race.ts",
    "chars": 3968,
    "preview": "import { HomeAssistant } from \"custom-card-helpers\";\nimport { html, HTMLTemplateResult } from \"lit-html\";\nimport { until"
  },
  {
    "path": "src/cards/results.ts",
    "chars": 17631,
    "preview": "import { html, HTMLTemplateResult } from \"lit-html\";\nimport { until } from 'lit-html/directives/until.js';\nimport Formul"
  },
  {
    "path": "src/cards/schedule.ts",
    "chars": 3726,
    "preview": "import { formatTime, HomeAssistant } from \"custom-card-helpers\";\nimport { html, HTMLTemplateResult } from \"lit-html\";\nim"
  },
  {
    "path": "src/consts.ts",
    "chars": 98,
    "preview": "export const CARD_NAME = 'formulaone-card';\nexport const CARD_EDITOR_NAME = `${CARD_NAME}-editor`;"
  },
  {
    "path": "src/directives/action-handler-directive.ts",
    "chars": 5660,
    "preview": "/* istanbul ignore file */\nimport { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';\n"
  },
  {
    "path": "src/editor.ts",
    "chars": 6973,
    "preview": "import EditorForm from '@marcokreeft/ha-editor-formbuilder';\nimport { FormControlType } from \"@marcokreeft/ha-editor-for"
  },
  {
    "path": "src/fonts.ts",
    "chars": 335,
    "preview": "export const loadCustomFonts = () => {\n    \n    if(window && document.fonts) {\n        // Load the F1 font using the CSS"
  },
  {
    "path": "src/index.ts",
    "chars": 5124,
    "preview": "import * as packageJson from '../package.json';\nimport { property, customElement } from 'lit/decorators.js';\nimport { Ho"
  },
  {
    "path": "src/lib/constants.ts",
    "chars": 1123,
    "preview": "export const ImageConstants = {\n    FlagCDN : 'https://flagcdn.com/w320/',\n    TeamLogoCDNLegacy : 'https://www.formula1"
  },
  {
    "path": "src/lib/format_date.ts",
    "chars": 635,
    "preview": "// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_date.ts\nimport { FrontendLocal"
  },
  {
    "path": "src/lib/format_date_time.ts",
    "chars": 756,
    "preview": "// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_date_time.ts\n\nimport { Fronten"
  },
  {
    "path": "src/lib/format_time.ts",
    "chars": 356,
    "preview": "// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/format_time.ts\nimport { FrontendLocal"
  },
  {
    "path": "src/lib/use_am_pm.ts",
    "chars": 638,
    "preview": "// Source: https://github.com/home-assistant/frontend/blob/dev/src/common/datetime/use_am_pm.ts\nimport { FrontendLocaleD"
  },
  {
    "path": "src/styles.ts",
    "chars": 1738,
    "preview": "import { css } from 'lit';\n\nexport const styles = css`   \n    table {\n        width: 100%;\n        border-spacing: 0;\n  "
  },
  {
    "path": "src/types/formulaone-card-types.ts",
    "chars": 3965,
    "preview": "import { ActionConfig, ActionHandlerOptions, HomeAssistant, LovelaceCardConfig } from 'custom-card-helpers';\nimport { HT"
  },
  {
    "path": "src/types/rest-country-types.ts",
    "chars": 1253,
    "preview": "export interface Flags {\n    svg: string;\n    png: string;\n}\n\nexport interface Currency {\n    code: string;\n    name: st"
  },
  {
    "path": "src/utils.ts",
    "chars": 18954,
    "preview": "import { ActionHandlerEvent, handleAction, hasAction, HomeAssistant } from \"custom-card-helpers\";\nimport { html, HTMLTem"
  },
  {
    "path": "test/configuration.yaml",
    "chars": 235,
    "preview": "homeassistant:\n  time_zone: Europe/Amsterdam\n  temperature_unit: C\n  unit_system: metric\n\ndefault_config:\n\nlovelace:\n  m"
  },
  {
    "path": "test/lovelace.yaml",
    "chars": 1082,
    "preview": "views:\n  - theme: Backend-selected\n    title: f1\n    path: f1\n    icon: mdi:car-sports\n    subview: false\n    badges: []"
  },
  {
    "path": "tests/api/ergast-client.test.ts",
    "chars": 5900,
    "preview": "import ErgastClient from \"../../src/api/ergast-client\";\nimport LocalStorageMock from \"../testdata/localStorageMock\";\nimp"
  },
  {
    "path": "tests/api/image-client.test.ts",
    "chars": 3676,
    "preview": "// Mocks\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock from \"../testdata/localStorageMock\";\n\n// Model"
  },
  {
    "path": "tests/api/restcountry-client.test.ts",
    "chars": 1717,
    "preview": "// Mocks\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock from \"../testdata/localStorageMock\";\n\n// Model"
  },
  {
    "path": "tests/api/weather-client.test.ts",
    "chars": 799,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\n\n// Models\nimport WeatherCl"
  },
  {
    "path": "tests/cards/base-card.test.ts",
    "chars": 1692,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport FormulaOneCard from \"../../src\";\n\n// Models\nimport WeatherCli"
  },
  {
    "path": "tests/cards/constructor-standings.test.ts",
    "chars": 7722,
    "preview": "// Mocks\nimport { createMock } from 'ts-auto-mock';\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/cards/countdown.test.ts",
    "chars": 17947,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/cards/driver-standings.test.ts",
    "chars": 22890,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/cards/last-result.test.ts",
    "chars": 6700,
    "preview": "// Mocks\nimport { createMock } from 'ts-auto-mock';\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/cards/next-race.test.ts",
    "chars": 36515,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/cards/results.test.ts",
    "chars": 106649,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\n\n// Models\nimport FormulaOn"
  },
  {
    "path": "tests/cards/schedule.test.ts",
    "chars": 37267,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/config.ts",
    "chars": 94,
    "preview": "import 'jest-ts-auto-mock';\nimport fetchMock from \"jest-fetch-mock\";\n\nfetchMock.enableMocks();"
  },
  {
    "path": "tests/index.test.ts",
    "chars": 41937,
    "preview": "// Mocks\nimport { createMock } from \"ts-auto-mock\";\nimport fetchMock from \"jest-fetch-mock\";\nimport LocalStorageMock fro"
  },
  {
    "path": "tests/lib/formate_date.test.ts",
    "chars": 1089,
    "preview": "import { FrontendLocaleData, NumberFormat, TimeFormat } from \"custom-card-helpers\";\nimport { formatDate, formatDateNumer"
  },
  {
    "path": "tests/lib/formate_date_time.test.ts",
    "chars": 2423,
    "preview": "import { FrontendLocaleData, NumberFormat, TimeFormat } from \"custom-card-helpers\";\nimport { formatDateTime, formatDateT"
  },
  {
    "path": "tests/lib/formate_time.test.ts",
    "chars": 525,
    "preview": "import { FrontendLocaleData, NumberFormat, TimeFormat } from \"custom-card-helpers\";\nimport { formatTime } from \"../../sr"
  },
  {
    "path": "tests/testdata/constructorStandings.json",
    "chars": 2328,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/2022/constructorstandin"
  },
  {
    "path": "tests/testdata/countries.json",
    "chars": 274131,
    "preview": "[{\"name\":\"Afghanistan\",\"topLevelDomain\":[\".af\"],\"alpha2Code\":\"AF\",\"alpha3Code\":\"AFG\",\"callingCodes\":[\"93\"],\"capital\":\"Ka"
  },
  {
    "path": "tests/testdata/driverStandings.json",
    "chars": 10120,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/2022/driverstandings.js"
  },
  {
    "path": "tests/testdata/localStorageMock.ts",
    "chars": 403,
    "preview": "export default class LocalStorageMock {\n    store: { [key: string]: string };\n\n    constructor() {\n        this.store = "
  },
  {
    "path": "tests/testdata/qualifying.json",
    "chars": 9180,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/2022/20/qualifying.json"
  },
  {
    "path": "tests/testdata/results.json",
    "chars": 12951,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/current/last/results.js"
  },
  {
    "path": "tests/testdata/schedule.json",
    "chars": 13866,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/2022.json\",\"limit\":\"30\""
  },
  {
    "path": "tests/testdata/seasons.json",
    "chars": 2669,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/seasons.json\",\"limit\":\""
  },
  {
    "path": "tests/testdata/sprint.json",
    "chars": 11978,
    "preview": "{\"MRData\":{\"xmlns\":\"http:\\/\\/ergast.com\\/mrd\\/1.5\",\"series\":\"f1\",\"url\":\"http://ergast.com/api/f1/2022/4/sprint.json\",\"li"
  },
  {
    "path": "tests/utils/calculateWindDirection.test.ts",
    "chars": 826,
    "preview": "import { calculateWindDirection } from \"../../src/utils\";\n\ndescribe('Testing util file function calculateWindDirection '"
  },
  {
    "path": "tests/utils/checkConfig.test.ts",
    "chars": 445,
    "preview": "import { checkConfig } from '../../src/utils';\nimport { FormulaOneCardConfig } from '../../src/types/formulaone-card-typ"
  },
  {
    "path": "tests/utils/getCircuitName.test.ts",
    "chars": 602,
    "preview": "import { getCircuitName } from '../../src/utils';\n\ndescribe('Testing util file function getCircuitName', () => {\n    tes"
  },
  {
    "path": "tests/utils/getCountryFlagUrl.test.ts",
    "chars": 1207,
    "preview": "// Mocks\nimport { createMock } from 'ts-auto-mock';\n\nimport { getCountryFlagByName } from '../../src/utils';\nimport * as"
  },
  {
    "path": "tests/utils/getDriverName.test.ts",
    "chars": 997,
    "preview": "import { createMock } from 'ts-auto-mock';\nimport { Driver } from '../../src/api/f1-models';\nimport { FormulaOneCardConf"
  },
  {
    "path": "tests/utils/getRefreshTime.test.ts",
    "chars": 1581,
    "preview": "import { getRefreshTime } from '../../src/utils';\nimport { MRData as scheduleData } from '../testdata/schedule.json'\nimp"
  },
  {
    "path": "tests/utils/getTeamImageUrl.test.ts",
    "chars": 799,
    "preview": "import { getTeamImage } from '../../src/utils';\nimport { MRData } from '../testdata/constructorStandings.json'\nimport 'i"
  },
  {
    "path": "tests/utils/hasConfigOrEntitiesChanged.test.ts",
    "chars": 1526,
    "preview": "import { PropertyValues } from \"lit\";\nimport { createMock } from \"ts-auto-mock\";\nimport FormulaOneCard from \"../../src\";"
  },
  {
    "path": "tests/utils/reduceArray.test.ts",
    "chars": 1596,
    "preview": "import { reduceArray } from \"../../src/utils\";\n\ndescribe('reduceArray', () => {\n    it('should reduce the array to the s"
  },
  {
    "path": "tests/utils/renderHeader.test.ts",
    "chars": 6612,
    "preview": "import { HomeAssistant } from \"custom-card-helpers\";\nimport { HTMLTemplateResult } from \"lit\";\nimport { createMock } fro"
  },
  {
    "path": "tests/utils/renderLastYearsResults.test.ts",
    "chars": 3149,
    "preview": "import { HomeAssistant, NumberFormat, TimeFormat } from \"custom-card-helpers\";\nimport { createMock } from \"ts-auto-mock\""
  },
  {
    "path": "tests/utils/renderRaceInfo.test.ts",
    "chars": 12855,
    "preview": "import { HomeAssistant, NumberFormat, TimeFormat } from 'custom-card-helpers';\nimport { createMock } from 'ts-auto-mock'"
  },
  {
    "path": "tests/utils/renderWeatherInfo.test.ts",
    "chars": 5807,
    "preview": "import { createMock } from \"ts-auto-mock\";\nimport { Day, Hour } from \"../../src/api/weather-models\";\nimport { FormulaOne"
  },
  {
    "path": "tests/utils.ts",
    "chars": 5748,
    "preview": "import { HTMLTemplateResult } from \"lit\";\n\nexport const getRenderString = (data: HTMLTemplateResult) : string => {\n    \n"
  },
  {
    "path": "tsconfig.json",
    "chars": 568,
    "preview": "{\n    \"compilerOptions\": {\n      \"experimentalDecorators\": true,\n      \"module\": \"CommonJS\",\n      \"noImplicitAny\": true"
  },
  {
    "path": "webpack.config.js",
    "chars": 1245,
    "preview": "const webpack = require('webpack');\nconst path = require('path');\nconst compressionPlugin = require('compression-webpack"
  }
]

About this extraction

This page contains the full source code of the marcokreeft87/formulaone-card GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 89 files (809.4 KB), approximately 245.9k tokens, and a symbol index with 193 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!