├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── dependabot.yml └── workflows │ ├── release.yml │ ├── lint.yml │ └── validate.yml ├── custom_components └── home_maintenance │ ├── panel │ ├── src │ │ ├── const.ts │ │ ├── helpers.ts │ │ ├── types.ts │ │ ├── data │ │ │ └── websockets.ts │ │ ├── styles.ts │ │ └── main.ts │ ├── tsconfig.json │ ├── package.json │ ├── localize │ │ ├── localize.ts │ │ └── languages │ │ │ ├── en.json │ │ │ └── de.json │ └── dist │ │ └── main.js │ ├── manifest.json │ ├── services.yaml │ ├── translations │ ├── en.json │ └── de.json │ ├── const.py │ ├── panel.py │ ├── config_flow.py │ ├── binary_sensor.py │ ├── __init__.py │ ├── store.py │ └── websocket.py ├── requirements.txt ├── screenshots ├── task-panel.PNG ├── entity-attributes.PNG └── integration-page.PNG ├── scripts ├── lint ├── setup └── develop ├── hacs.json ├── config └── configuration.yaml ├── .gitignore ├── .ruff.toml ├── LICENSE ├── .devcontainer.json ├── CONTRIBUTING.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/const.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = '1.5.2'; 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant==2025.5.0 3 | pip>=21.3.1 4 | ruff==0.11.13 -------------------------------------------------------------------------------- /screenshots/task-panel.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJPoorman/home_maintenance/HEAD/screenshots/task-panel.PNG -------------------------------------------------------------------------------- /screenshots/entity-attributes.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJPoorman/home_maintenance/HEAD/screenshots/entity-attributes.PNG -------------------------------------------------------------------------------- /screenshots/integration-page.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJPoorman/home_maintenance/HEAD/screenshots/integration-page.PNG -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix 9 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Home Maintenance", 3 | "homeassistant": "2025.5.0", 4 | "hacs": "2.0.5", 5 | "render_readme": true, 6 | "zip_release": true, 7 | "filename": "home_maintenance.zip", 8 | "hide_default_branch": true 9 | } -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | 9 | apt-get update 10 | 11 | apt-get install -y curl 12 | 13 | curl -fsSL https://deb.nodesource.com/setup_18.x | bash - 14 | 15 | apt-get install -y nodejs -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/homeassistant/ 5 | homeassistant: 6 | debug: true 7 | 8 | # https://www.home-assistant.io/integrations/logger/ 9 | logger: 10 | default: info 11 | logs: 12 | custom_components: debug 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | # Typescript 9 | custom_components/home_maintenance/panel/node_modules 10 | package-lock.json 11 | 12 | 13 | # misc 14 | .coverage 15 | .vscode 16 | *.code-workspace 17 | coverage.xml 18 | .ruff_cache 19 | *.zip 20 | 21 | 22 | # Home Assistant configuration 23 | config/* 24 | !config/configuration.yaml -------------------------------------------------------------------------------- /custom_components/home_maintenance/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "home_maintenance", 3 | "name": "Home Maintenance", 4 | "after_dependencies": [ 5 | "frontend" 6 | ], 7 | "codeowners": [ 8 | "@TJPoorman" 9 | ], 10 | "config_flow": true, 11 | "dependencies": [ 12 | "http", 13 | "panel_custom", 14 | "tag" 15 | ], 16 | "documentation": "https://github.com/TJPoorman/home_maintenance", 17 | "iot_class": "local_polling", 18 | "issue_tracker": "https://github.com/TJPoorman/home_maintenance/issues", 19 | "requirements": [], 20 | "version": "v0.0.0" 21 | } 22 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "useDefineForClassFields": false, 8 | "jsx": "react", 9 | "jsxFactory": "html", 10 | "jsxFragmentFactory": "html", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "allowSyntheticDefaultImports": true, 15 | "resolveJsonModule": true, 16 | "outDir": "./dist" 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "devcontainers" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | 14 | - package-ecosystem: "pip" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | ignore: 19 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 20 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /custom_components/home_maintenance/services.yaml: -------------------------------------------------------------------------------- 1 | reset_last_performed: 2 | name: Reset Last Performed 3 | description: > 4 | Resets the 'last_performed' date for a maintenance task entity, and updates the 'next_due' date based on the interval. 5 | fields: 6 | entity_id: 7 | name: Entity ID 8 | description: The ID of the task entity to reset. 9 | required: true 10 | selector: 11 | entity: 12 | domain: binary_sensor 13 | performed_date: 14 | name: Performed Date 15 | description: Optionally specify the date the task was last performed. 16 | required: false 17 | example: "2025-06-01" 18 | selector: 19 | date: -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py313" 4 | 5 | [lint] 6 | select = [ 7 | "ALL", 8 | ] 9 | 10 | ignore = [ 11 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 12 | "D203", # no-blank-line-before-class (incompatible with formatter) 13 | "D212", # multi-line-summary-first-line (incompatible with formatter) 14 | "COM812", # incompatible with formatter 15 | "ISC001", # incompatible with formatter 16 | ] 17 | 18 | [lint.flake8-pytest-style] 19 | fixture-parentheses = false 20 | 21 | [lint.pyupgrade] 22 | keep-runtime-typing = true 23 | 24 | [lint.mccabe] 25 | max-complexity = 25 26 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "panel", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "esbuild src/main.ts --bundle --outfile=dist/main.js --format=esm --target=es2020 --minify", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "TJPoorman", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "esbuild": "^0.25.5", 15 | "lit": "^3.3.0", 16 | "typescript": "^5.8.3" 17 | }, 18 | "dependencies": { 19 | "custom-card-helpers": "^1.9.0", 20 | "@material/mwc-select": "latest", 21 | "@material/mwc-list": "latest", 22 | "@mdi/js": "7.4.47", 23 | "@mdi/svg": "7.4.47" 24 | } 25 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Get version 13 | id: version 14 | uses: home-assistant/actions/helpers/version@master 15 | 16 | - name: Patch manifest and zip 17 | run: | 18 | sed -i 's/v0.0.0/${{ steps.version.outputs.version }}/' custom_components/home_maintenance/manifest.json 19 | 20 | cd custom_components/home_maintenance/ 21 | zip ../../home_maintenance.zip ./* translations/* panel/dist/* -x '.*' 22 | - name: Upload ZIP to Release 23 | uses: softprops/action-gh-release@v1 24 | with: 25 | files: home_maintenance.zip 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | ruff: 15 | name: "Ruff" 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - name: Checkout the repository 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 23 | with: 24 | python-version: "3.13" 25 | cache: "pip" 26 | 27 | - name: Install requirements 28 | run: python3 -m pip install -r requirements.txt 29 | 30 | - name: Lint 31 | run: python3 -m ruff check . 32 | 33 | - name: Format 34 | run: python3 -m ruff format . --check 35 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Home Maintenance", 4 | "step": { 5 | "user": { 6 | "title": "Configure Home Maintenance", 7 | "description": "Set up the Home Maintenance integration.", 8 | "data": { 9 | "admin_only": "Restrict panel to admins only", 10 | "sidebar_title": "The name shown in the sidebar for this integration." 11 | } 12 | } 13 | }, 14 | "abort": { 15 | "single_instance_allowed": "Only a single instance of Home Maintenance is allowed." 16 | } 17 | }, 18 | "options": { 19 | "step": { 20 | "init": { 21 | "data": { 22 | "admin_only": "Restrict panel to admins only", 23 | "sidebar_title": "The name shown in the sidebar for this integration." 24 | } 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /custom_components/home_maintenance/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Home Maintenance", 4 | "step": { 5 | "user": { 6 | "title": "Home Maintenance konfigurieren", 7 | "description": "Richten Sie die Home Maintenance-Integration ein.", 8 | "data": { 9 | "admin_only": "Panel nur für Administratoren anzeigen", 10 | "sidebar_title": "Der Name, der in der Seitenleiste für diese Integration angezeigt wird." 11 | } 12 | } 13 | }, 14 | "abort": { 15 | "single_instance_allowed": "Es ist nur eine einzige Instanz von Home Maintenance erlaubt." 16 | } 17 | }, 18 | "options": { 19 | "step": { 20 | "init": { 21 | "data": { 22 | "admin_only": "Panel nur für Administratoren anzeigen", 23 | "sidebar_title": "Der Name, der in der Seitenleiste für diese Integration angezeigt wird." 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 18 | name: Hassfest validation 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout the repository 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Run hassfest validation 25 | uses: home-assistant/actions/hassfest@a19f5f4e08ef2786e4604a948f62addd937a6bc9 # master 26 | 27 | hacs: # https://github.com/hacs/action 28 | name: HACS validation 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Run HACS validation 32 | uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0 33 | with: 34 | category: integration 35 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/helpers.ts: -------------------------------------------------------------------------------- 1 | export const loadConfigDashboard = async () => { 2 | await customElements.whenDefined("partial-panel-resolver"); 3 | const ppResolver = document.createElement("partial-panel-resolver"); 4 | const routes = (ppResolver as any)._getRoutes([ 5 | { 6 | component_name: "config", 7 | url_path: "a", 8 | }, 9 | ]); 10 | await routes?.routes?.a?.load?.(); 11 | await customElements.whenDefined("ha-panel-config"); 12 | const configRouter: any = document.createElement("ha-panel-config"); 13 | await configRouter?.routerOptions?.routes?.dashboard?.load?.(); // Load ha-config-dashboard 14 | await configRouter?.routerOptions?.routes?.general?.load?.(); // Load ha-settings-row 15 | await configRouter?.routerOptions?.routes?.entities?.load?.(); // Load ha-data-table 16 | await configRouter?.routerOptions?.routes?.labels?.load?.(); 17 | await customElements.whenDefined("ha-config-dashboard"); 18 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2025 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import * as en from './languages/en.json'; 2 | 3 | import IntlMessageFormat from 'intl-messageformat'; 4 | 5 | var languages: any = { 6 | en: en, 7 | }; 8 | 9 | export function localize(string: string, language: string, ...args: any[]): string { 10 | const lang = language.replace(/['"]+/g, ''); 11 | 12 | var translated: string; 13 | 14 | try { 15 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]); 16 | } catch (e) { 17 | translated = string.split('.').reduce((o, i) => o[i], languages['en']); 18 | } 19 | 20 | if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']); 21 | 22 | if (!args.length) return translated; 23 | 24 | const argObject: Record = {}; 25 | for (let i = 0; i < args.length; i += 2) { 26 | let key = args[i]; 27 | key = key.replace(/^{([^}]+)?}$/, '$1'); 28 | argObject[key] = args[i + 1]; 29 | } 30 | 31 | try { 32 | const message = new IntlMessageFormat(translated, language); 33 | return message.format(argObject) as string; 34 | } catch (err) { 35 | return 'Translation ' + err; 36 | } 37 | } -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/types.ts: -------------------------------------------------------------------------------- 1 | import { localize } from '../localize/localize' 2 | 3 | export type IntervalType = "days" | "weeks" | "months"; 4 | 5 | export const INTERVAL_TYPES: IntervalType[] = ["days", "weeks", "months"]; 6 | 7 | export function getIntervalTypeLabels(lang: string): Record { 8 | return { 9 | days: localize("intervals.days", lang), 10 | weeks: localize("intervals.weeks", lang), 11 | months: localize("intervals.months", lang), 12 | }; 13 | } 14 | 15 | export interface IntegrationConfig { 16 | data: Record; 17 | options: Record; 18 | } 19 | 20 | export interface Label { 21 | label_id: string; 22 | name: string; 23 | color?: string; 24 | icon?: string; 25 | } 26 | 27 | export interface Tag { 28 | id: string; 29 | name?: string; 30 | } 31 | 32 | export interface EntityRegistryEntry { 33 | entity_id: string; 34 | unique_id: string; 35 | platform: string; 36 | device_id?: string; 37 | disabled_by?: string | null; 38 | area_id?: string | null; 39 | original_name?: string; 40 | icon?: string; 41 | labels: string[]; 42 | } 43 | 44 | export interface Task { 45 | id: string; 46 | title: string; 47 | interval_value: number; 48 | interval_type: IntervalType; 49 | last_performed: string; 50 | tag_id?: string; 51 | icon?: string; 52 | } -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TJPoorman/home_maintenance", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "charliermarsh.ruff", 18 | "github.vscode-pull-request-github", 19 | "ms-python.python", 20 | "ms-python.vscode-pylance", 21 | "ryanluker.vscode-coverage-gutters" 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "editor.formatOnPaste": true, 27 | "editor.formatOnSave": true, 28 | "editor.formatOnType": false, 29 | "files.trimTrailingWhitespace": true, 30 | "python.analysis.typeCheckingMode": "basic", 31 | "python.analysis.autoImportCompletions": true, 32 | "python.defaultInterpreterPath": "/usr/local/bin/python", 33 | "[python]": { 34 | "editor.defaultFormatter": "charliermarsh.ruff" 35 | } 36 | } 37 | } 38 | }, 39 | "remoteUser": "vscode", 40 | "features": { 41 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 42 | "packages": [ 43 | "ffmpeg", 44 | "libturbojpeg0", 45 | "libpcap-dev" 46 | ] 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /custom_components/home_maintenance/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Home Maintenance integration.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.helpers import config_validation as cv 6 | 7 | VERSION = "1.5.2" 8 | NAME = "Home Maintenance" 9 | MANUFACTURER = "@TJPoorman" 10 | 11 | DOMAIN = "home_maintenance" 12 | 13 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 14 | 15 | PANEL_FILENAME = "panel/main.js" 16 | PANEL_URL = "home-maintenance" 17 | PANEL_API_PATH = "/home_maintenance_static" 18 | PANEL_API_URL = PANEL_API_PATH + "/main.js" 19 | PANEL_TITLE = NAME 20 | PANEL_ICON = "mdi:hammer-wrench" 21 | PANEL_NAME = "home-maintenance-panel" 22 | 23 | DEVICE_KEY = "home_maintenance_hub" 24 | 25 | SERVICE_RESET = "reset_last_performed" 26 | SERVICE_RESET_SCHEMA = vol.Schema( 27 | { 28 | vol.Required("entity_id"): cv.entity_id, 29 | vol.Optional("performed_date"): cv.string, 30 | } 31 | ) 32 | 33 | CONFIG_STEP_USER_DATA_SCHEMA = vol.Schema( 34 | { 35 | vol.Optional("admin_only", default=True): cv.boolean, 36 | vol.Optional("sidebar_title", default=PANEL_TITLE): cv.string, 37 | } 38 | ) 39 | 40 | 41 | def get_options_schema(config_entry: ConfigEntry) -> vol.Schema: 42 | """Return the schema for get options.""" 43 | return vol.Schema( 44 | { 45 | vol.Optional( 46 | "admin_only", 47 | default=config_entry.options.get( 48 | "admin_only", config_entry.data.get("admin_only", True) 49 | ), 50 | ): cv.boolean, 51 | vol.Optional( 52 | "sidebar_title", 53 | default=config_entry.options.get( 54 | "sidebar_title", 55 | config_entry.data.get("sidebar_title", PANEL_TITLE), 56 | ), 57 | ): cv.string, 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel.py: -------------------------------------------------------------------------------- 1 | """Support for Home Maintenance custom panel.""" 2 | 3 | import logging 4 | import os 5 | 6 | from homeassistant.components import frontend, panel_custom 7 | from homeassistant.components.http import StaticPathConfig 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .const import ( 12 | PANEL_API_PATH, 13 | PANEL_API_URL, 14 | PANEL_ICON, 15 | PANEL_NAME, 16 | PANEL_TITLE, 17 | PANEL_URL, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | async def async_register_panel(hass: HomeAssistant, entry: ConfigEntry) -> None: 24 | """Register custom panel for Home Maintenance.""" 25 | static_path = os.path.join(os.path.dirname(__file__), "panel", "dist") # noqa: PTH118, PTH120 26 | 27 | # Register static path only once, since it cannot be removed on unload 28 | if not hass.data.setdefault("home_maintenance_static_path_registered", False): 29 | await hass.http.async_register_static_paths( 30 | [StaticPathConfig(PANEL_API_PATH, static_path, cache_headers=False)] 31 | ) 32 | hass.data["home_maintenance_static_path_registered"] = True 33 | 34 | admin_only = entry.options.get("admin_only", entry.data.get("admin_only", True)) 35 | sidebar_title = entry.options.get( 36 | "sidebar_title", entry.data.get("sidebar_title", PANEL_TITLE) 37 | ) 38 | 39 | await panel_custom.async_register_panel( 40 | hass, 41 | webcomponent_name=PANEL_NAME, 42 | frontend_url_path=PANEL_URL, 43 | module_url=PANEL_API_URL, 44 | sidebar_title=sidebar_title, 45 | sidebar_icon=PANEL_ICON, 46 | require_admin=admin_only, 47 | config={}, 48 | ) 49 | 50 | 51 | def async_unregister_panel(hass: HomeAssistant) -> None: 52 | """Remove custom panel for Home Maintenenance.""" 53 | frontend.async_remove_panel(hass, PANEL_URL) 54 | _LOGGER.debug("Removing panel") 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 8 | - type: checkboxes 9 | attributes: 10 | label: Checklist 11 | options: 12 | - label: I have filled out the template to the best of my ability. 13 | required: true 14 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 15 | required: true 16 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/TJPoorman/home_maintenance/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 17 | required: true 18 | - label: I have read the "Important Note" section of the readme and this feature request fits the scope of the integration. 19 | required: true 20 | 21 | - type: textarea 22 | attributes: 23 | label: "Is your feature request related to a problem? Please describe." 24 | description: "A clear and concise description of what the problem is." 25 | placeholder: "I'm always frustrated when [...]" 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | attributes: 31 | label: "Describe the solution you'd like" 32 | description: "A clear and concise description of what you want to happen." 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | attributes: 38 | label: "Describe alternatives you've considered" 39 | description: "A clear and concise description of any alternative solutions or features you've considered." 40 | validations: 41 | required: true 42 | 43 | - type: textarea 44 | attributes: 45 | label: "Additional context" 46 | description: "Add any other context or screenshots about the feature request here." 47 | validations: 48 | required: true 49 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/data/websockets.ts: -------------------------------------------------------------------------------- 1 | import { EntityRegistryEntry, Tag, Task, IntegrationConfig, Label } from '../types'; 2 | import type { HomeAssistant } from "custom-card-helpers"; 3 | 4 | export const loadTags = (hass: HomeAssistant): Promise => 5 | hass.connection.sendMessagePromise({ 6 | type: 'tag/list', 7 | }); 8 | 9 | export const loadRegistryEntries = (hass: HomeAssistant): Promise => 10 | hass.callWS({ 11 | type: "config/entity_registry/list", 12 | }); 13 | 14 | export const loadLabelRegistry = (hass: HomeAssistant): Promise => 15 | hass.callWS({ 16 | type: "config/label_registry/list", 17 | }); 18 | 19 | export const loadTasks = (hass: HomeAssistant): Promise => 20 | hass.callWS({ 21 | type: 'home_maintenance/get_tasks', 22 | }); 23 | 24 | export const loadTask = (hass: HomeAssistant, id: string): Promise => 25 | hass.callWS({ 26 | type: 'home_maintenance/get_task', 27 | task_id: id, 28 | }) 29 | 30 | export const saveTask = (hass: HomeAssistant, payload: Record): Promise => 31 | hass.callWS({ 32 | type: 'home_maintenance/add_task', 33 | ...payload, 34 | }) 35 | 36 | export const removeTask = (hass: HomeAssistant, id: string): Promise => 37 | hass.callWS({ 38 | type: 'home_maintenance/remove_task', 39 | task_id: id, 40 | }); 41 | 42 | export const completeTask = (hass: HomeAssistant, id: string): Promise => 43 | hass.callWS({ 44 | type: 'home_maintenance/complete_task', 45 | task_id: id, 46 | }) 47 | 48 | export const updateTask = (hass: HomeAssistant, payload: Record): Promise => 49 | hass.callWS({ 50 | type: 'home_maintenance/update_task', 51 | ...payload, 52 | }) 53 | 54 | export const getConfig = (hass: HomeAssistant): Promise => 55 | hass.callWS({ 56 | type: 'home_maintenance/get_config', 57 | }) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 8 | - type: textarea 9 | attributes: 10 | label: "System Health details" 11 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 12 | validations: 13 | required: true 14 | - type: checkboxes 15 | attributes: 16 | label: Checklist 17 | options: 18 | - label: I have enabled debug logging for my installation. 19 | required: true 20 | - label: I have filled out the issue template to the best of my ability. 21 | required: true 22 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 23 | required: true 24 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/TJPoorman/home_maintenance/issues?q=is%3Aissue+label%3A%22Bug%22+).. 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: "Describe the issue" 29 | description: "A clear and concise description of what the issue is." 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Reproduction steps 35 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 36 | value: | 37 | 1. 38 | 2. 39 | 3. 40 | ... 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: "Debug logs" 46 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 47 | render: text 48 | validations: 49 | required: true 50 | 51 | - type: textarea 52 | attributes: 53 | label: "Diagnostics dump" 54 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 55 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Home Maintenance integration.""" 2 | 3 | import secrets 4 | from typing import Any 5 | 6 | from homeassistant.config_entries import ( 7 | CONN_CLASS_LOCAL_POLL, 8 | ConfigEntry, 9 | ConfigFlow, 10 | ConfigFlowResult, 11 | OptionsFlow, 12 | ) 13 | from homeassistant.core import callback 14 | 15 | from .const import ( 16 | CONFIG_STEP_USER_DATA_SCHEMA, 17 | DOMAIN, 18 | NAME, 19 | get_options_schema, 20 | ) 21 | 22 | 23 | class HomeMaintenanceConfigFlow(ConfigFlow, domain=DOMAIN): 24 | """Config flow for Home Maintenenance.""" 25 | 26 | VERSION = "1.1.0" 27 | CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL 28 | 29 | async def async_step_user( 30 | self, user_input: dict[str, Any] | None = None 31 | ) -> ConfigFlowResult: 32 | """Handle a flow initialized by the user.""" 33 | # Only allow a single instance 34 | if self._async_current_entries(): 35 | return self.async_abort(reason="single_instance_allowed") 36 | 37 | if user_input is None: 38 | return self.async_show_form( 39 | step_id="user", data_schema=CONFIG_STEP_USER_DATA_SCHEMA 40 | ) 41 | 42 | new_id = secrets.token_hex(6) 43 | 44 | await self.async_set_unique_id(new_id) 45 | self._abort_if_unique_id_configured(updates=user_input) 46 | 47 | return self.async_create_entry( 48 | title=NAME, 49 | data=user_input, 50 | options=user_input, 51 | ) 52 | 53 | @staticmethod 54 | @callback 55 | def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: 56 | """Handle callback for options flow.""" 57 | return HomeMaintenanceOptionsFlowHandler(config_entry) 58 | 59 | 60 | class HomeMaintenanceOptionsFlowHandler(OptionsFlow): 61 | """Options flow for Home Maintenenance.""" 62 | 63 | def __init__(self, config_entry: ConfigEntry) -> None: 64 | """Initialize setup of options flow.""" 65 | self.config_entry = config_entry 66 | 67 | async def async_step_init( 68 | self, user_input: dict[str, Any] | None 69 | ) -> ConfigFlowResult: 70 | """Handle a flow initialized by the user.""" 71 | if user_input is not None: 72 | result = self.async_create_entry(title="", data=user_input) 73 | self.hass.async_create_task( 74 | self.hass.config_entries.async_reload(self.config_entry.entry_id) 75 | ) 76 | return result 77 | 78 | return self.async_show_form( 79 | step_id="init", 80 | data_schema=get_options_schema(self.config_entry), 81 | ) 82 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/localize/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "loading": "Loading...", 4 | "none": "None", 5 | "no_tasks": "No tasks found." 6 | }, 7 | "intervals": { 8 | "day": "Day", 9 | "days": "Days", 10 | "week": "Week", 11 | "weeks": "Weeks", 12 | "month": "Month", 13 | "months": "Months" 14 | }, 15 | "panel": { 16 | "cards": { 17 | "new": { 18 | "title": "Create New Task", 19 | "fields": { 20 | "title": { 21 | "heading": "Task Title" 22 | }, 23 | "interval_value": { 24 | "heading": "Interval" 25 | }, 26 | "interval_type": { 27 | "heading": "Interval Type" 28 | }, 29 | "last_performed": { 30 | "heading": "Last Performed", 31 | "helper": "Leave blank to use today" 32 | }, 33 | "tag": { 34 | "heading": "Tag" 35 | }, 36 | "icon": { 37 | "heading": "Icon" 38 | }, 39 | "label": { 40 | "heading": "Label(s)" 41 | } 42 | }, 43 | "sections": { 44 | "optional": "Optional settings" 45 | }, 46 | "actions": { 47 | "add_task": "Add Task" 48 | }, 49 | "alerts": { 50 | "required": "Please fill all fields", 51 | "error": "Error adding task. See console for details." 52 | } 53 | }, 54 | "current": { 55 | "title": "Current Tasks", 56 | "no_items": "No tasks found.", 57 | "every": "every", 58 | "last": "Last", 59 | "next": "Next Due", 60 | "actions": { 61 | "complete": "Complete", 62 | "edit": "Edit", 63 | "remove": "Remove" 64 | }, 65 | "confirm_remove": "Are you sure you want to remove this task?" 66 | } 67 | }, 68 | "dialog": { 69 | "edit_task": { 70 | "title": "Edit Task", 71 | "fields": { 72 | "interval_value": { 73 | "heading": "Interval" 74 | }, 75 | "interval_type": { 76 | "heading": "Interval Type" 77 | }, 78 | "last_performed": { 79 | "heading": "Last Performed", 80 | "helper": "Leave blank to use today" 81 | }, 82 | "tag": { 83 | "heading": "Tag" 84 | }, 85 | "icon": { 86 | "heading": "Icon" 87 | }, 88 | "label": { 89 | "heading": "Label(s)" 90 | } 91 | }, 92 | "sections": { 93 | "optional": "Optional settings" 94 | }, 95 | "actions": { 96 | "cancel": "Cancel", 97 | "save": "Save" 98 | } 99 | } 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'lit'; 2 | 3 | export const commonStyle = css` 4 | :host { 5 | color: var(--primary-text-color); 6 | background: var(--lovelace-background, var(--primary-background-color)); 7 | } 8 | 9 | .header { 10 | background-color: var(--app-header-background-color); 11 | color: var(--app-header-text-color, white); 12 | border-bottom: var(--app-header-border-bottom, none); 13 | } 14 | 15 | .toolbar { 16 | height: var(--header-height); 17 | display: flex; 18 | align-items: center; 19 | font-size: 20px; 20 | padding: 0 16px; 21 | font-weight: 400; 22 | box-sizing: border-box; 23 | } 24 | 25 | .main-title { 26 | margin: 0 0 0 24px; 27 | line-height: 20px; 28 | flex-grow: 1; 29 | } 30 | 31 | .version { 32 | font-size: 14px; 33 | font-weight: 500; 34 | color: rgba(var(--rgb-text-primary-color), 0.9); 35 | } 36 | 37 | .view { 38 | height: calc(100vh - 65px); 39 | display: flex; 40 | align-content: start; 41 | justify-content: center; 42 | flex-wrap: wrap; 43 | align-items: flex-start; 44 | } 45 | 46 | ha-card { 47 | display: block; 48 | margin: 5px; 49 | } 50 | 51 | .card-new { 52 | width: 500px; 53 | max-width: 500px; 54 | } 55 | 56 | .card-current { 57 | width: 850px; 58 | max-width: 850px; 59 | } 60 | 61 | ha-expansion-panel { 62 | --input-fill-color: none; 63 | } 64 | 65 | .form-row { 66 | display: flex; 67 | justify-content: center; 68 | gap: 8px; 69 | flex-wrap: wrap; 70 | } 71 | 72 | .form-field, 73 | ha-textfield, 74 | ha-select, 75 | ha-icon-picker { 76 | min-width: 265px; 77 | } 78 | 79 | .filler { 80 | flex-grow: 1; 81 | } 82 | 83 | .break { 84 | flex-basis: 100%; 85 | height: 0; 86 | } 87 | 88 | @media (max-width: 600px) { 89 | .form-row { 90 | flex-direction: column; /* Stack fields vertically */ 91 | } 92 | 93 | .form-field { 94 | width: 100%; /* Full width */ 95 | } 96 | 97 | ha-textfield, 98 | ha-select, 99 | ha-icon-picker { 100 | width: 100%; 101 | box-sizing: border-box; 102 | } 103 | } 104 | 105 | .task-list { 106 | list-style: none; 107 | padding: 0; 108 | margin: 0; 109 | } 110 | 111 | .task-item { 112 | display: flex; 113 | flex-wrap: wrap; 114 | justify-content: space-between; 115 | align-items: center; 116 | margin-bottom: 12px; 117 | gap: 1rem; 118 | padding: 0.5rem 0; 119 | border-bottom: 1px solid var(--divider-color); 120 | } 121 | 122 | .task-header { 123 | display: flex; 124 | align-items: center; 125 | gap: 8px; 126 | } 127 | 128 | .task-content { 129 | flex: 1; 130 | } 131 | 132 | .task-actions { 133 | display: flex; 134 | flex-direction: row; 135 | gap: 0.5rem; 136 | } 137 | 138 | .due-soon { 139 | color: var(--error-color, red); 140 | font-weight: bold; 141 | } 142 | 143 | .warning { 144 | --mdc-theme-primary: var(--error-color); 145 | color: var(--primary-text-color); 146 | } 147 | 148 | ha-dialog { 149 | --mdc-dialog-min-width: 600px; 150 | } 151 | 152 | @media (max-width: 600px) { 153 | ha-dialog { 154 | --mdc-dialog-min-width: auto; 155 | } 156 | } 157 | `; -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/localize/languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "loading": "Wird geladen...", 4 | "none": "Keine", 5 | "no_tasks": "Keine Aufgaben gefunden." 6 | }, 7 | "intervals": { 8 | "day": "Tag", 9 | "days": "Tage", 10 | "week": "Woche", 11 | "weeks": "Wochen", 12 | "month": "Monat", 13 | "months": "Monate" 14 | }, 15 | "panel": { 16 | "cards": { 17 | "new": { 18 | "title": "Neue Aufgabe erstellen", 19 | "fields": { 20 | "title": { 21 | "heading": "Aufgabentitel" 22 | }, 23 | "interval_value": { 24 | "heading": "Intervall" 25 | }, 26 | "interval_type": { 27 | "heading": "Intervalltyp" 28 | }, 29 | "last_performed": { 30 | "heading": "Zuletzt durchgeführt", 31 | "helper": "Leer lassen, um heutiges Datum zu verwenden" 32 | }, 33 | "tag": { 34 | "heading": "Tag" 35 | }, 36 | "icon": { 37 | "heading": "Symbol" 38 | }, 39 | "label": { 40 | "heading": "Bezeichnung(en)" 41 | } 42 | }, 43 | "sections": { 44 | "optional": "Optionale Einstellungen" 45 | }, 46 | "actions": { 47 | "add_task": "Aufgabe hinzufügen" 48 | }, 49 | "alerts": { 50 | "required": "Bitte alle Felder ausfüllen", 51 | "error": "Fehler beim Hinzufügen der Aufgabe. Siehe Konsole für Details." 52 | } 53 | }, 54 | "current": { 55 | "title": "Aktuelle Aufgaben", 56 | "no_items": "Keine Aufgaben gefunden.", 57 | "every": "alle", 58 | "last": "Zuletzt", 59 | "next": "Nächste Fälligkeit", 60 | "actions": { 61 | "complete": "Abschließen", 62 | "edit": "Bearbeiten", 63 | "remove": "Entfernen" 64 | }, 65 | "confirm_remove": "Sind Sie sicher, dass Sie diese Aufgabe entfernen möchten?" 66 | } 67 | }, 68 | "dialog": { 69 | "edit_task": { 70 | "title": "Aufgabe bearbeiten", 71 | "fields": { 72 | "interval_value": { 73 | "heading": "Intervall" 74 | }, 75 | "interval_type": { 76 | "heading": "Intervalltyp" 77 | }, 78 | "last_performed": { 79 | "heading": "Zuletzt durchgeführt", 80 | "helper": "Leer lassen, um heutiges Datum zu verwenden" 81 | }, 82 | "tag": { 83 | "heading": "Tag" 84 | }, 85 | "icon": { 86 | "heading": "Symbol" 87 | }, 88 | "label": { 89 | "heading": "Bezeichnung(en)" 90 | } 91 | }, 92 | "sections": { 93 | "optional": "Optionale Einstellungen" 94 | }, 95 | "actions": { 96 | "cancel": "Abbrechen", 97 | "save": "Speichern" 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏠 Home Maintenance Tracker for Home Assistant 2 | 3 | Keep your home in top shape by tracking recurring maintenance tasks right inside Home Assistant! 4 | 5 | This custom integration helps you remember important chores like changing air filters, cleaning gutters, or testing smoke alarms — and shows you when they're due. 6 | 7 | --- 8 | 9 | ## ✨ What It Does 10 | 11 | - 📋 Lets you create recurring tasks (e.g., “Change HVAC filter every 90 days”) 12 | - 🔔 Creates entities in Home Assistant to be able to create automations and display on dashboards 13 | - ✅ Lets you mark tasks as completed so it can track the next due date 14 | - 📊 Shows tasks in a clean, easy-to-use interface built into Home Assistant 15 | 16 | --- 17 | 18 | ## ⚠️ Important Note 19 | This integration was created to fill a simple but important gap in Home Assistant: the ability to create recurring tasks without relying on multiple helpers and automations. It is intentionally minimal by design — focused solely on task tracking. 20 | 21 | Home Assistant already provides powerful features for dashboards, automations, and alerts, and this integration is meant to complement those, not replace them. 22 | 23 | Because it's a custom component with limited scope and resources, not all feature requests will be added or considered — especially if the functionality already exists natively in Home Assistant or falls outside the intended purpose of the integration. 24 | 25 | Thank you for understanding and helping keep this integration focused and maintainable. 26 | 27 | --- 28 | 29 | ## 🖼️ Screenshots 30 | 31 | - ![Task Panel](screenshots/task-panel.PNG) 32 | - ![Integration Page](screenshots/integration-page.PNG) 33 | - ![Entity Attributes](screenshots/entity-attributes.PNG) 34 | 35 | --- 36 | 37 | ## 🛠️ Installation 38 | 39 | [![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=TJPoorman&repository=home_maintenance&category=Integration) 40 | 41 |
42 | Click to show installation instructions 43 |
    44 |
  1. Install files:
  2. 45 |
      46 |
    • Using HACS:
      47 | In the HACS panel, search for 'Home Maintenance', open the repository and click 'Download'.
    • 48 |
    • Manually:
      49 | Download the latest release as a zip file and extract it into the `custom_components` folder in your HA installation.
    • 50 |
    51 |
  3. Restart HA to load the integration into HA.
  4. 52 |
  5. Go to Settings -> Devices & services and click 'ADD INTEGRATION' button. Look for Home Maintenance and click to add it.
  6. 53 |
  7. The Home Maintenance integration is ready for use. You can find the configuration panel in the menu on the left.
  8. 54 |
55 |
56 | 57 | --- 58 | 59 | ## 🛠️ How to Use 60 | 61 | - Open **Home Maintenance** from the Home Assistant sidebar. 62 | - To add a new task enter: 63 | - A title (e.g., “Clean Dryer Vent”) 64 | - How often it needs to be done 65 | - Select the interval period (Defaults to days) 66 | - The last time you did it (Optional. If omitted will be today) 67 | - Select an NFC tag (Optional. Will mark the task complete when scanned) 68 | - Select an icon (Optional) 69 | - Click **Add Task** 70 | - Tasks will show if they are due or overdue 71 | - Click **Complete** to reset the Last Performed date to today 72 | 73 | --- 74 | 75 | ## 🔄 Example Tasks 76 | 77 | | Task | Interval | Last Done | 78 | |----------------------|----------|---------------| 79 | | Change HVAC Filter | 90 days | Jan 15, 2025 | 80 | | Test Smoke Alarms | 6 months | Dec 1, 2024 | 81 | | Clean Gutters | 8 weeks | Oct 1, 2024 | 82 | 83 | --- 84 | 85 | ## 🔁 Available Services 86 | 87 | ### `home_maintenance.reset_last_performed` 88 | 89 | Marks a specific task as completed and updates its `last_performed` and `next_due`. 90 | 91 | Optionally specify a date for `last_performed`. 92 | 93 | #### Example service call: 94 | 95 | ```yaml 96 | service: home_maintenance.reset_last_performed 97 | data: 98 | entity_id: binary_sensor.clean_gutters 99 | performed_date: "2025-06-19" 100 | ``` 101 | 102 | --- 103 | 104 | ## 💬 Need Help? 105 | 106 | Open an issue here on GitHub or ask in the Home Assistant community. 107 | 108 | [Home Assistant Community Thread](https://community.home-assistant.io/t/new-integration-home-maintenance-track-recurring-tasks-in-home-assistant/897324) 109 | 110 | --- 111 | 112 | ## 📄 License 113 | 114 | MIT License – free to use, share, and improve. 115 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Home Maintenance binary sensors.""" 2 | 3 | import logging 4 | from datetime import datetime, timedelta 5 | 6 | from dateutil.relativedelta import relativedelta 7 | from homeassistant.components.binary_sensor import BinarySensorEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers import entity_registry as er 11 | from homeassistant.helpers.device_registry import DeviceInfo 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.util import dt as dt_util 14 | 15 | from . import const 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_setup_entry( 21 | hass: HomeAssistant, 22 | entry: ConfigEntry, # noqa: ARG001 23 | async_add_entities: AddEntitiesCallback, 24 | ) -> None: 25 | """Set up the Home Maintenance binary sensor platform.""" 26 | if const.DOMAIN not in hass.data: 27 | hass.data[const.DOMAIN] = {} 28 | hass.data[const.DOMAIN]["add_entities"] = async_add_entities 29 | 30 | device_id = hass.data[const.DOMAIN].get("device_id") 31 | store = hass.data[const.DOMAIN].get("store") 32 | 33 | entities = [] 34 | for task in store.get_all(): 35 | entity = HomeMaintenanceSensor(hass, task, device_id) 36 | entities.append(entity) 37 | hass.data[const.DOMAIN]["entities"][task["id"]] = entity 38 | 39 | async_add_entities(entities) 40 | 41 | 42 | class HomeMaintenanceSensor(BinarySensorEntity): 43 | """Representation of a Home Maintenance binary sensor.""" 44 | 45 | def __init__( 46 | self, 47 | hass: HomeAssistant, 48 | task: dict, 49 | device_id: str, 50 | labels: list[str] | None = None, 51 | ) -> None: 52 | """Initialize the Home Maintenance sensor.""" 53 | self.hass = hass 54 | self.task = task 55 | self._attr_name = task["title"] 56 | self._attr_unique_id = f"{task['id']}" 57 | self._device_id = device_id 58 | self._labels = labels or [] 59 | self._update_state() 60 | 61 | @property 62 | def device_info(self) -> DeviceInfo | None: 63 | """Return device information for this sensor.""" 64 | return DeviceInfo( 65 | identifiers={(const.DOMAIN, const.DEVICE_KEY)}, 66 | name=const.NAME, 67 | model=const.NAME, 68 | sw_version=const.VERSION, 69 | manufacturer=const.MANUFACTURER, 70 | ) 71 | 72 | @property 73 | def icon(self) -> str | None: 74 | """Return the icon for the task.""" 75 | return self.task.get("icon", "mdi:calendar-check") 76 | 77 | def _calculate_next_due( 78 | self, last_performed: datetime, interval_value: int, interval_type: str 79 | ) -> datetime: 80 | """Calculate the next date based on last date and interval.""" 81 | if interval_type == "days": 82 | return last_performed + timedelta(days=interval_value) 83 | if interval_type == "weeks": 84 | return last_performed + timedelta(weeks=interval_value) 85 | if interval_type == "months": 86 | return last_performed + relativedelta(months=interval_value) 87 | 88 | return last_performed 89 | 90 | def _update_state(self) -> None: 91 | """Get the latest state of the sensor.""" 92 | last = dt_util.parse_datetime(self.task["last_performed"]) 93 | if last is None: 94 | self._attr_is_on = True 95 | self._attr_extra_state_attributes = { 96 | "last_performed": self.task["last_performed"], 97 | "interval_value": self.task["interval_value"], 98 | "interval_type": self.task["interval_type"], 99 | "next_due": "unknown", 100 | } 101 | if self.task["tag_id"]: 102 | self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"] 103 | return 104 | 105 | if last.tzinfo is None: 106 | last = dt_util.as_utc(last) 107 | 108 | interval_value = self.task["interval_value"] 109 | interval_type = self.task["interval_type"] 110 | due_date = self._calculate_next_due( 111 | last, interval_value, interval_type 112 | ).replace(hour=0, minute=0, second=0, microsecond=0) 113 | 114 | self._attr_is_on = ( 115 | dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) >= due_date 116 | ) 117 | self._attr_extra_state_attributes = { 118 | "last_performed": self.task["last_performed"], 119 | "interval_value": self.task["interval_value"], 120 | "interval_type": self.task["interval_type"], 121 | "next_due": due_date.isoformat(), 122 | } 123 | if self.task["tag_id"]: 124 | self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"] 125 | 126 | async def async_update(self) -> None: 127 | """Get the latest state of the sensor.""" 128 | self._update_state() 129 | 130 | async def async_added_to_hass(self) -> None: 131 | """Run when entity is added to Home Assistant.""" 132 | if self._labels: 133 | registry = er.async_get(self.hass) 134 | if registry.async_get(self.entity_id): 135 | registry.async_update_entity(self.entity_id, labels=set(self._labels)) 136 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Home Maintenance platform.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | from typing import cast 6 | 7 | from homeassistant.components.binary_sensor import DOMAIN as PLATFORM 8 | from homeassistant.components.tag.const import EVENT_TAG_SCANNED 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import Event, HomeAssistant, ServiceCall, callback 11 | from homeassistant.helpers import device_registry as dr 12 | from homeassistant.helpers import entity_registry as er 13 | from homeassistant.helpers.entity_registry import RegistryEntry # noqa: TC002 14 | from homeassistant.helpers.typing import ConfigType 15 | from homeassistant.util import dt as dt_util 16 | 17 | from . import const 18 | from .panel import ( 19 | async_register_panel, 20 | async_unregister_panel, 21 | ) 22 | from .store import TaskStore 23 | from .websocket import async_register_websockets 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | CONFIG_SCHEMA = const.CONFIG_SCHEMA 28 | 29 | 30 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001 31 | """Track states and offer events for sensors.""" 32 | return True 33 | 34 | 35 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 36 | """Set up the Home Maintenance config entry.""" 37 | 38 | @callback 39 | def handle_tag_scanned_event(event: Event) -> None: 40 | """Handle when a tag is scanned.""" 41 | tag_id = event.data.get("tag_id") # Actually tag UUID 42 | 43 | store = hass.data[const.DOMAIN].get("store") 44 | tasks = store.get_by_tag_uuid(tag_id) 45 | if not tasks: 46 | return 47 | 48 | _LOGGER.debug("Tag scanned: %s", tag_id) 49 | 50 | for task in tasks: 51 | task_id = task["id"] 52 | store.update_last_performed(task_id) 53 | 54 | # Initialize and load stored tasks 55 | task_store = TaskStore(hass) 56 | await task_store.async_load() 57 | 58 | # Register Device 59 | device_registry = dr.async_get(hass) 60 | device = device_registry.async_get_or_create( 61 | config_entry_id=entry.entry_id, 62 | identifiers={(const.DOMAIN, const.DEVICE_KEY)}, 63 | name=const.NAME, 64 | model=const.NAME, 65 | sw_version=const.VERSION, 66 | manufacturer=const.MANUFACTURER, 67 | ) 68 | 69 | hass.data.setdefault(const.DOMAIN, {}) 70 | hass.data[const.DOMAIN] = { 71 | "add_entities": None, 72 | "entry_id": entry.entry_id, 73 | "device_id": device.id, 74 | "store": task_store, 75 | "entities": {}, 76 | } 77 | 78 | await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM]) 79 | 80 | # Register the panel (frontend) 81 | await async_register_panel(hass, entry) 82 | 83 | # Websocket support 84 | await async_register_websockets(hass) 85 | 86 | # Register custom services 87 | register_services(hass) 88 | 89 | # Register event listener for tag scanned 90 | unsub = hass.bus.async_listen(EVENT_TAG_SCANNED, handle_tag_scanned_event) 91 | hass.data[const.DOMAIN]["unsub_tag_scanned"] = unsub 92 | 93 | return True 94 | 95 | 96 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 97 | """Unload Home Maintenance config entry.""" 98 | unload_ok = await hass.config_entries.async_unload_platforms(entry, [PLATFORM]) 99 | if not unload_ok: 100 | return False 101 | 102 | if "unsub_tag_scanned" in hass.data[const.DOMAIN]: 103 | hass.data[const.DOMAIN]["unsub_tag_scanned"]() 104 | 105 | async_unregister_panel(hass) 106 | hass.data.pop(const.DOMAIN, None) 107 | return True 108 | 109 | 110 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 111 | """Handle reload of a config entry.""" 112 | await async_unload_entry(hass, entry) 113 | await async_setup_entry(hass, entry) 114 | 115 | 116 | async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: # noqa: ARG001 117 | """Remove Home Maintenance config entry.""" 118 | async_unregister_panel(hass) 119 | del hass.data[const.DOMAIN] 120 | 121 | 122 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # noqa: ARG001 123 | """Handle migration of config entry.""" 124 | return True 125 | 126 | 127 | @callback 128 | def register_services(hass: HomeAssistant) -> None: 129 | """Register services used by home maintenance component.""" 130 | 131 | async def async_srv_reset(call: ServiceCall) -> None: 132 | entity_id = call.data["entity_id"] 133 | performed_date_str = call.data.get("performed_date") 134 | 135 | performed_date = None 136 | if performed_date_str is not None: 137 | parsed_date = dt_util.parse_date(performed_date_str) 138 | if parsed_date is None: 139 | msg = f"Could not parse performed_date: {performed_date_str}" 140 | raise ValueError(msg) 141 | combined_date = datetime.combine(parsed_date, datetime.min.time()) 142 | performed_date = dt_util.as_local(combined_date) 143 | 144 | entity_registry = er.async_get(hass) 145 | entry = cast("RegistryEntry", entity_registry.async_get(entity_id)) 146 | task_id = entry.unique_id 147 | entity = hass.data[const.DOMAIN]["entities"].get(task_id) 148 | if entity is None: 149 | return 150 | 151 | store = hass.data[const.DOMAIN].get("store") 152 | store.update_last_performed(task_id, performed_date) 153 | 154 | hass.services.async_register( 155 | const.DOMAIN, 156 | const.SERVICE_RESET, 157 | async_srv_reset, 158 | schema=const.SERVICE_RESET_SCHEMA, 159 | ) 160 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/store.py: -------------------------------------------------------------------------------- 1 | """Store Home Maintenance configuration.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | 6 | import attr 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import entity_registry, storage 9 | from homeassistant.util import dt as dt_util 10 | 11 | from . import const 12 | from .binary_sensor import HomeMaintenanceSensor 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | STORAGE_KEY = f"{const.DOMAIN}.storage" 17 | STORAGE_VERSION_MAJOR = 1 18 | STORAGE_VERSION_MINOR = 1 19 | 20 | 21 | @attr.s(slots=True) 22 | class HomeMaintenanceTask: 23 | """Represents a single home maintenance task.""" 24 | 25 | id: str = attr.ib() 26 | title: str = attr.ib() 27 | interval_value: int = attr.ib() 28 | interval_type: str = attr.ib() 29 | last_performed: str = attr.ib() 30 | tag_id: str | None = attr.ib(default=None) 31 | icon: str | None = attr.ib(default=None) 32 | 33 | 34 | class TaskStore: 35 | """Class to hold home maintenance task data.""" 36 | 37 | def __init__(self, hass: HomeAssistant) -> None: 38 | """Initialize the storage.""" 39 | self.hass = hass 40 | self._store = storage.Store( 41 | hass, 42 | STORAGE_VERSION_MAJOR, 43 | STORAGE_KEY, 44 | minor_version=STORAGE_VERSION_MINOR, 45 | ) 46 | self._tasks: dict[str, HomeMaintenanceTask] = {} 47 | 48 | async def async_load(self) -> None: 49 | """Load tasks from storage.""" 50 | data = await self._store.async_load() 51 | if data is None: 52 | return 53 | 54 | self._tasks = { 55 | task_data["id"]: HomeMaintenanceTask(**task_data) for task_data in data 56 | } 57 | 58 | def get_all(self) -> list[dict]: 59 | """Get all tasks.""" 60 | return [attr.asdict(t) for t in self._tasks.values()] 61 | 62 | def get(self, task_id: str) -> dict: 63 | """Get single task.""" 64 | return attr.asdict(self._tasks.get(task_id)) 65 | 66 | def _get_tag_uuids(self) -> dict[str, str]: 67 | """Return a mapping of all task's tag friendly IDs into tag UUIDs.""" 68 | er = entity_registry.async_get(self.hass) 69 | 70 | # Get each task's tag_id, if configured 71 | tag_ids = [t.tag_id for t in self._tasks.values() if t.tag_id] 72 | 73 | tag_uuids = {} 74 | for tag_id in tag_ids: 75 | # If two tasks have the same tag_id, only get the first 76 | if tag_id in tag_uuids: 77 | continue 78 | 79 | # Get the tag_id -> tag_uuid mapping from entity_registry 80 | entry = er.async_get(tag_id) 81 | if entry: 82 | tag_uuids[tag_id] = entry.unique_id 83 | 84 | return tag_uuids 85 | 86 | def get_by_tag_uuid(self, tag_uuid: str) -> list[dict]: 87 | """Get tasks given a tag UUID.""" 88 | tag_uuids = self._get_tag_uuids() 89 | 90 | return [ 91 | attr.asdict(t) 92 | for t in self._tasks.values() 93 | if t.tag_id and tag_uuids.get(t.tag_id) == tag_uuid 94 | ] 95 | 96 | def get_by_tag_id(self, tag_id: str) -> list[dict]: 97 | """Get tasks by tag id.""" 98 | return [attr.asdict(t) for t in self._tasks.values() if t.tag_id == tag_id] 99 | 100 | def add( 101 | self, task: HomeMaintenanceTask, labels: list[str] | None = None 102 | ) -> str | None: 103 | """Add new task.""" 104 | add_entities = self.hass.data[const.DOMAIN].get("add_entities") 105 | if not add_entities: 106 | msg = "add_entities not registered yet." 107 | raise RuntimeError(msg) 108 | return None 109 | 110 | device_id = self.hass.data[const.DOMAIN].get("device_id") 111 | if not device_id: 112 | msg = "Device ID not available." 113 | raise RuntimeError(msg) 114 | return None 115 | 116 | entity = HomeMaintenanceSensor( 117 | self.hass, attr.asdict(task), device_id, labels=labels 118 | ) 119 | add_entities([entity]) 120 | self._tasks[task.id] = task 121 | self.hass.data[const.DOMAIN]["entities"][task.id] = entity 122 | self._save() 123 | 124 | return entity.unique_id 125 | 126 | def delete(self, task_id: str) -> None: 127 | """Remove a task.""" 128 | er = entity_registry.async_get(self.hass) 129 | 130 | # Search for entity by unique_id 131 | entity_entry = next( 132 | ( 133 | entry 134 | for entry in er.entities.values() 135 | if entry.unique_id == task_id and entry.platform == const.DOMAIN 136 | ), 137 | None, 138 | ) 139 | if entity_entry is None: 140 | msg = f"No entity found with task ID {task_id}." 141 | raise RuntimeError(msg) 142 | return 143 | 144 | # Remove the entity by entity_id 145 | er.async_remove(entity_entry.entity_id) 146 | 147 | # Remove from your task list and persist 148 | del self._tasks[task_id] 149 | self._save() 150 | 151 | def update_task(self, task_id: str, updated: dict) -> None: 152 | """Update an existing task with new values from a dictionary.""" 153 | entity = self.hass.data[const.DOMAIN]["entities"].get(task_id) 154 | task = self._tasks.get(task_id) 155 | 156 | if entity is None or task is None: 157 | msg = "Task not found." 158 | raise RuntimeError(msg) 159 | 160 | for key, value in updated.items(): 161 | entity.task[key] = value 162 | if hasattr(task, key): 163 | setattr(task, key, value) 164 | 165 | if "tag_id" in updated: 166 | tag_id = updated["tag_id"] 167 | task.tag_id = tag_id if tag_id else None 168 | entity.task["tag_id"] = tag_id if tag_id else None 169 | 170 | if "labels" in updated: 171 | registry = entity_registry.async_get(self.hass) 172 | if registry.async_get(entity.entity_id): 173 | registry.async_update_entity( 174 | entity.entity_id, 175 | labels=set(updated["labels"]), 176 | ) 177 | 178 | self.hass.async_create_task(entity.async_update_ha_state(force_refresh=True)) 179 | self._save() 180 | 181 | def update_last_performed( 182 | self, task_id: str, performed_date: datetime | None = None 183 | ) -> None: 184 | """Update a task's last performed date.""" 185 | entity = self.hass.data[const.DOMAIN]["entities"].get(task_id) 186 | task = self._tasks.get(task_id) 187 | 188 | if entity is None or task is None: 189 | msg = "Task not found." 190 | raise RuntimeError(msg) 191 | 192 | if performed_date is None: 193 | performed_date = dt_util.now() 194 | performed_date_str = performed_date.replace( 195 | hour=0, minute=0, second=0, microsecond=0 196 | ).isoformat() 197 | 198 | entity.task["last_performed"] = performed_date_str 199 | task.last_performed = performed_date_str 200 | self.hass.async_create_task(entity.async_update_ha_state(force_refresh=True)) 201 | self._save() 202 | 203 | def _save(self) -> None: 204 | """Save tasks in the background.""" 205 | self.hass.async_create_task( 206 | self._store.async_save([attr.asdict(task) for task in self._tasks.values()]) 207 | ) 208 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/websocket.py: -------------------------------------------------------------------------------- 1 | """Websocket commands for the Home Maintenance integration.""" 2 | 3 | import uuid 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant.components import websocket_api 8 | from homeassistant.components.websocket_api import connection, messages 9 | from homeassistant.core import HomeAssistant, callback 10 | from homeassistant.util import dt as dt_util 11 | 12 | from .const import DOMAIN 13 | from .store import HomeMaintenanceTask 14 | 15 | 16 | @callback 17 | def websocket_get_tasks( 18 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 19 | ) -> None: 20 | """Get all tasks.""" 21 | store = hass.data[DOMAIN].get("store") 22 | result = store.get_all() 23 | connection.send_result(msg["id"], result) 24 | 25 | 26 | @callback 27 | def websocket_get_task( 28 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 29 | ) -> None: 30 | """Get single tasks.""" 31 | store = hass.data[DOMAIN].get("store") 32 | task_id = msg["task_id"] 33 | result = store.get(task_id) 34 | connection.send_result(msg["id"], result) 35 | 36 | 37 | @callback 38 | def websocket_add_task( 39 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 40 | ) -> None: 41 | """Add a new task.""" 42 | store = hass.data[DOMAIN].get("store") 43 | 44 | last_str = msg["last_performed"] 45 | if last_str: 46 | parsed = dt_util.parse_datetime(last_str) 47 | if parsed is None: 48 | connection.send_error( 49 | msg["id"], "invalid_date", f"Could not parse date: {last_str}" 50 | ) 51 | return 52 | parsed_local = dt_util.as_local(parsed) 53 | last_performed = parsed_local.replace( 54 | hour=0, minute=0, second=0, microsecond=0 55 | ).isoformat() 56 | else: 57 | last_performed = ( 58 | dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0).isoformat() 59 | ) 60 | 61 | new_task = HomeMaintenanceTask( 62 | id=f"home_maintenance_{uuid.uuid4().hex}", 63 | title=msg["title"], 64 | interval_value=msg["interval_value"], 65 | interval_type=msg["interval_type"], 66 | last_performed=last_performed, 67 | tag_id=msg.get("tag_id"), 68 | icon=msg.get("icon"), 69 | ) 70 | 71 | labels = msg.get("labels", []) 72 | new_id = store.add(new_task, labels) 73 | connection.send_result(msg["id"], {"success": True, "id": new_id}) 74 | 75 | 76 | @callback 77 | def websocket_update_task( 78 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 79 | ) -> None: 80 | """Update a tasks values.""" 81 | store = hass.data[DOMAIN].get("store") 82 | task_id = msg["task_id"] 83 | updates = msg.get("updates", {}) 84 | 85 | last_str = updates["last_performed"] 86 | if last_str: 87 | parsed = dt_util.parse_datetime(last_str) 88 | if parsed is None: 89 | connection.send_error( 90 | msg["id"], "invalid_date", f"Could not parse date: {last_str}" 91 | ) 92 | return 93 | parsed_local = dt_util.as_local(parsed) 94 | last_performed = parsed_local.replace( 95 | hour=0, minute=0, second=0, microsecond=0 96 | ).isoformat() 97 | else: 98 | last_performed = ( 99 | dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0).isoformat() 100 | ) 101 | updates["last_performed"] = last_performed 102 | 103 | store.update_task(task_id, updates) 104 | connection.send_result(msg["id"], {"success": True}) 105 | 106 | 107 | @callback 108 | def websocket_complete_task( 109 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 110 | ) -> None: 111 | """Mark a task as completed.""" 112 | store = hass.data[DOMAIN].get("store") 113 | task_id = msg["task_id"] 114 | store.update_last_performed(task_id) 115 | connection.send_result(msg["id"], {"success": True}) 116 | 117 | 118 | @callback 119 | def websocket_remove_task( 120 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 121 | ) -> None: 122 | """Remove a task.""" 123 | store = hass.data[DOMAIN].get("store") 124 | task_id = msg["task_id"] 125 | store.delete(task_id) 126 | connection.send_result(msg["id"], {"success": True}) 127 | 128 | 129 | @callback 130 | def websocket_get_config( 131 | hass: HomeAssistant, connection: connection.ActiveConnection, msg: dict[str, Any] 132 | ) -> None: 133 | """Retrieve integration configuration.""" 134 | entries = hass.config_entries.async_entries(DOMAIN) 135 | 136 | if not entries: 137 | connection.send_error( 138 | msg["id"], "not_found", "No config entry found for your_domain" 139 | ) 140 | return 141 | 142 | entry = entries[0] 143 | 144 | connection.send_result( 145 | msg["id"], 146 | { 147 | "data": dict(entry.data), 148 | "options": dict(entry.options), 149 | }, 150 | ) 151 | 152 | 153 | async def async_register_websockets(hass: HomeAssistant) -> None: 154 | """Register websocket commands.""" 155 | websocket_api.async_register_command( 156 | hass, 157 | "home_maintenance/get_tasks", 158 | websocket_get_tasks, 159 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 160 | {vol.Required("type"): "home_maintenance/get_tasks"} 161 | ), 162 | ) 163 | 164 | websocket_api.async_register_command( 165 | hass, 166 | "home_maintenance/get_task", 167 | websocket_get_task, 168 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 169 | { 170 | vol.Required("type"): "home_maintenance/get_task", 171 | vol.Required("task_id"): str, 172 | } 173 | ), 174 | ) 175 | 176 | websocket_api.async_register_command( 177 | hass, 178 | "home_maintenance/add_task", 179 | websocket_add_task, 180 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 181 | { 182 | vol.Required("type"): "home_maintenance/add_task", 183 | vol.Required("title"): str, 184 | vol.Required("interval_value"): int, 185 | vol.Required("interval_type"): str, 186 | vol.Optional("last_performed"): str, 187 | vol.Optional("tag_id"): str, 188 | vol.Optional("icon"): str, 189 | vol.Optional("labels"): [str], 190 | } 191 | ), 192 | ) 193 | 194 | websocket_api.async_register_command( 195 | hass, 196 | "home_maintenance/update_task", 197 | websocket_update_task, 198 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 199 | { 200 | vol.Required("type"): "home_maintenance/update_task", 201 | vol.Required("task_id"): str, 202 | vol.Required("updates"): dict, 203 | } 204 | ), 205 | ) 206 | 207 | websocket_api.async_register_command( 208 | hass, 209 | "home_maintenance/complete_task", 210 | websocket_complete_task, 211 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 212 | { 213 | vol.Required("type"): "home_maintenance/complete_task", 214 | vol.Required("task_id"): str, 215 | } 216 | ), 217 | ) 218 | 219 | websocket_api.async_register_command( 220 | hass, 221 | "home_maintenance/remove_task", 222 | websocket_remove_task, 223 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 224 | { 225 | vol.Required("type"): "home_maintenance/remove_task", 226 | vol.Required("task_id"): str, 227 | } 228 | ), 229 | ) 230 | 231 | websocket_api.async_register_command( 232 | hass, 233 | "home_maintenance/get_config", 234 | websocket_get_config, 235 | messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( 236 | { 237 | vol.Required("type"): "home_maintenance/get_config", 238 | } 239 | ), 240 | ) 241 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mdiCheckCircleOutline, 3 | mdiDelete, 4 | mdiPencil, 5 | } from "@mdi/js"; 6 | import { LitElement, html, nothing } from "lit"; 7 | import { property, state } from "lit/decorators.js"; 8 | import type { HomeAssistant } from "custom-card-helpers"; 9 | import { formatDateNumeric } from "custom-card-helpers"; 10 | 11 | import { localize } from '../localize/localize'; 12 | import { VERSION } from "./const"; 13 | import { loadConfigDashboard } from "./helpers"; 14 | import { commonStyle } from './styles' 15 | import { EntityRegistryEntry, IntegrationConfig, IntervalType, INTERVAL_TYPES, getIntervalTypeLabels, Label, Task, Tag } from './types'; 16 | import { completeTask, getConfig, loadLabelRegistry, loadRegistryEntries, loadTags, loadTask, loadTasks, removeTask, saveTask, updateTask } from './data/websockets'; 17 | 18 | interface TaskFormData { 19 | title: string; 20 | interval_value: number | ""; 21 | interval_type: string; 22 | last_performed: string; 23 | icon: string; 24 | label: string[]; 25 | tag: string; 26 | } 27 | 28 | export class HomeMaintenancePanel extends LitElement { 29 | @property() hass?: HomeAssistant; 30 | @property() narrow!: boolean; 31 | 32 | @state() private tags: Tag[] | null = null; 33 | @state() private tasks: Task[] = []; 34 | @state() private config: IntegrationConfig | null = null; 35 | @state() private registry: EntityRegistryEntry[] = []; 36 | @state() private labelRegistry: Label[] = []; 37 | 38 | // New Task form state 39 | @state() private _formData: TaskFormData = { 40 | title: "", 41 | interval_value: "", 42 | interval_type: "days", 43 | last_performed: "", 44 | icon: "", 45 | label: [], 46 | tag: "", 47 | }; 48 | private _advancedOpen: boolean = false; 49 | 50 | // Edit dialog state 51 | @state() private _editingTaskId: string | null = null; 52 | @state() private _editFormData: TaskFormData = { 53 | title: "", 54 | interval_value: "", 55 | interval_type: "days", 56 | last_performed: "", 57 | icon: "", 58 | label: [], 59 | tag: "", 60 | }; 61 | 62 | private get _columns() { 63 | return { 64 | icon: { 65 | title: "", 66 | moveable: false, 67 | showNarrow: false, 68 | label: "icon", 69 | type: "icon", 70 | template: (task: Task) => 71 | task.icon ? html`` : nothing, 72 | }, 73 | tagIcon: { 74 | title: "", 75 | moveable: false, 76 | showNarrow: false, 77 | label: "tag", 78 | type: "icon", 79 | template: (task: any) => 80 | task.tagIcon ? html`` : nothing, 81 | }, 82 | title: { 83 | title: 'Title', 84 | main: true, 85 | showNarrow: true, 86 | sortable: true, 87 | filterable: true, 88 | grows: true, 89 | extraTemplate: (task: Task) => { 90 | const entity = this.registry.find((entry) => entry.unique_id === task.id); 91 | if (!entity) return nothing; 92 | 93 | const labels = this.labelRegistry.filter((lr) => entity.labels.includes(lr.label_id)); 94 | 95 | return labels.length 96 | ? html`` 97 | : nothing; 98 | }, 99 | }, 100 | interval_days: { 101 | title: 'Interval', 102 | showNarrow: false, 103 | sortable: true, 104 | minWidth: "100px", 105 | maxWidth: "100px", 106 | template: (task: Task) => { 107 | const type = task.interval_type; 108 | const isSingular = task.interval_value === 1; 109 | const labelKey = isSingular ? type.slice(0, -1) : type; 110 | return `${task.interval_value} ${localize(`intervals.${labelKey}`, this.hass!.language)}`; 111 | } 112 | }, 113 | last_performed: { 114 | title: 'Last Performed', 115 | showNarrow: false, 116 | sortable: true, 117 | minWidth: "150px", 118 | maxWidth: "150px", 119 | template: (task: Task) => { 120 | if (!task.last_performed) return "-"; 121 | 122 | const date = new Date(this.computeISODate(task.last_performed)); 123 | return formatDateNumeric(date, this.hass!.locale); 124 | } 125 | }, 126 | next_due: { 127 | title: localize('panel.cards.current.next', this.hass!.language), 128 | showNarrow: true, 129 | sortable: true, 130 | direction: "asc", 131 | minWidth: "100px", 132 | maxWidth: "100px", 133 | template: (task: any) => { 134 | const now = new Date(); 135 | const next = new Date(task.next_due); 136 | const isDue = next <= now; 137 | 138 | return html` 139 | 140 | ${formatDateNumeric(next, this.hass!.locale)} 141 | ` || "—"; 142 | }, 143 | }, 144 | complete: { 145 | minWidth: "64px", 146 | maxWidth: "64px", 147 | sortable: false, 148 | groupable: false, 149 | showNarrow: true, 150 | moveable: false, 151 | hideable: false, 152 | type: "overflow", 153 | template: (task: Task) => html` 154 | this._handleCompleteTaskClick(task.id)} 156 | .label="Complete" 157 | title="Mark Task Complete" 158 | .path=${mdiCheckCircleOutline} 159 | > 160 | `, 161 | }, 162 | actions: { 163 | title: "", 164 | label: "actions", 165 | showNarrow: true, 166 | moveable: false, 167 | hideable: false, 168 | type: "overflow-menu", 169 | template: (task: Task) => html` 170 | this._handleOpenEditDialogClick(task.id), 178 | }, 179 | { 180 | label: localize('panel.cards.current.actions.remove', this.hass!.language), 181 | path: mdiDelete, 182 | action: () => this._handleRemoveTaskClick(task.id), 183 | warning: true, 184 | }, 185 | ]} 186 | > 187 | 188 | `, 189 | }, 190 | } 191 | }; 192 | 193 | private get _columnsToDisplay() { 194 | return Object.fromEntries( 195 | Object.entries(this._columns).filter(([_, col]) => 196 | this.narrow ? col.showNarrow !== false : true 197 | ) 198 | ); 199 | } 200 | 201 | private get _rows() { 202 | return this.tasks.map((task: Task) => ({ 203 | icon: task.icon, 204 | id: task.id, 205 | title: task.title, 206 | interval_value: task.interval_value, 207 | interval_type: task.interval_type, 208 | last_performed: task.last_performed ?? 'Never', 209 | interval_days: (() => { 210 | switch (task.interval_type) { 211 | case "days": 212 | return task.interval_value; 213 | case "weeks": 214 | return task.interval_value * 7; 215 | case "months": 216 | return task.interval_value * 30; 217 | default: 218 | return Number.MAX_SAFE_INTEGER; 219 | } 220 | })(), 221 | next_due: (() => { 222 | const [datePart] = task.last_performed.split("T"); 223 | const [year, month, day] = datePart.split("-").map(Number); 224 | const next = new Date(year, month - 1, day); 225 | 226 | switch (task.interval_type) { 227 | case "days": 228 | next.setDate(next.getDate() + task.interval_value); 229 | break; 230 | case "weeks": 231 | next.setDate(next.getDate() + task.interval_value * 7); 232 | break; 233 | case "months": 234 | next.setMonth(next.getMonth() + task.interval_value); 235 | break; 236 | default: 237 | throw new Error(`Unsupported interval type: ${task.interval_type}`); 238 | } 239 | 240 | return next; 241 | })(), 242 | tagIcon: (() => task.tag_id && task.tag_id.trim() !== "" ? "mdi:tag" : undefined)(), 243 | })); 244 | } 245 | 246 | private get _basicSchema() { 247 | return [ 248 | { name: "title", required: true, selector: { text: {} }, }, 249 | { name: "interval_value", required: true, selector: { number: { min: 1, mode: "box" } }, }, 250 | { 251 | name: "interval_type", 252 | required: true, 253 | selector: { 254 | select: { 255 | options: INTERVAL_TYPES.map((type) => ({ 256 | value: type, 257 | label: getIntervalTypeLabels(this.hass!.language)[type], 258 | })), 259 | mode: "dropdown" 260 | }, 261 | }, 262 | }, 263 | ] 264 | }; 265 | 266 | private get _advancedSchema() { 267 | return [ 268 | { name: "last_performed", selector: { date: {} }, }, 269 | { name: "icon", selector: { icon: {} }, }, 270 | { name: "label", selector: { label: { multiple: true } }, }, 271 | { name: "tag", selector: { entity: { filter: { domain: "tag" } } }, }, 272 | ] 273 | }; 274 | 275 | private get _editSchema() { 276 | return [ 277 | { name: "interval_value", required: true, selector: { number: { min: 1, mode: "box" } }, }, 278 | { 279 | name: "interval_type", 280 | required: true, 281 | selector: { 282 | select: { 283 | options: INTERVAL_TYPES.map((type) => ({ 284 | value: type, 285 | label: getIntervalTypeLabels(this.hass!.language)[type], 286 | })), 287 | mode: "dropdown" 288 | }, 289 | }, 290 | }, 291 | { type: "constant", name: localize('panel.dialog.edit_task.sections.optional', this.hass!.language), disabled: true }, 292 | { name: "last_performed", selector: { date: {} }, }, 293 | { name: "icon", selector: { icon: {} }, }, 294 | { name: "label", selector: { label: { multiple: true } }, }, 295 | { name: "tag", selector: { entity: { filter: { domain: "tag" } } }, }, 296 | ] 297 | }; 298 | 299 | private _computeLabel = (schema: { name: string }): string => { 300 | try { 301 | return localize(`panel.cards.new.fields.${schema.name}.heading`, this.hass!.language) ?? schema.name; 302 | } catch { 303 | return schema.name; 304 | } 305 | } 306 | 307 | private _computeHelper = (schema: { name: string }): string => { 308 | try { 309 | return localize(`panel.cards.new.fields.${schema.name}.helper`, this.hass!.language) ?? ""; 310 | } catch { 311 | return ""; 312 | } 313 | } 314 | 315 | private _computeEditLabel = (schema: { name: string }): string => { 316 | try { 317 | return localize(`panel.dialog.edit_task.fields.${schema.name}.heading`, this.hass!.language) ?? schema.name; 318 | } catch { 319 | return schema.name; 320 | } 321 | } 322 | 323 | private _computeEditHelper = (schema: { name: string }): string => { 324 | try { 325 | return localize(`panel.dialog.edit_task.fields.${schema.name}.helper`, this.hass!.language) ?? ""; 326 | } catch { 327 | return ""; 328 | } 329 | } 330 | 331 | private async loadData() { 332 | await loadConfigDashboard(); 333 | this.tags = await loadTags(this.hass!); 334 | this.tasks = await loadTasks(this.hass!); 335 | this.config = await getConfig(this.hass!); 336 | this.registry = await loadRegistryEntries(this.hass!); 337 | this.labelRegistry = await loadLabelRegistry(this.hass!); 338 | } 339 | 340 | private async resetForm() { 341 | this._formData = { 342 | title: "", 343 | interval_value: "", 344 | interval_type: "days", 345 | last_performed: "", 346 | icon: "", 347 | label: [], 348 | tag: "", 349 | }; 350 | 351 | this.tasks = await loadTasks(this.hass!); 352 | } 353 | 354 | private async resetEditForm() { 355 | this._editFormData = { 356 | title: "", 357 | interval_value: "", 358 | interval_type: "days", 359 | last_performed: "", 360 | icon: "", 361 | label: [], 362 | tag: "", 363 | }; 364 | } 365 | 366 | private computeISODate(dateStr: string): string { 367 | let isoDateStr: string; 368 | 369 | if (dateStr) { 370 | // Only take the YYYY-MM-DD part to avoid time zone issues 371 | const [yearStr, monthStr, dayStr] = dateStr.split("T")[0].split("-"); 372 | const year = Number(yearStr); 373 | const month = Number(monthStr); 374 | const day = Number(dayStr); 375 | 376 | if (!isNaN(year) && !isNaN(month) && !isNaN(day)) { 377 | const parsedDate = new Date(year, month - 1, day); 378 | parsedDate.setHours(0, 0, 0, 0); 379 | isoDateStr = parsedDate.toISOString(); 380 | } else { 381 | alert("Invalid date entered."); 382 | const fallback = new Date(); 383 | fallback.setHours(0, 0, 0, 0); 384 | isoDateStr = fallback.toISOString(); 385 | } 386 | } else { 387 | const today = new Date(); 388 | today.setHours(0, 0, 0, 0); 389 | isoDateStr = today.toISOString(); 390 | } 391 | 392 | return isoDateStr; 393 | } 394 | 395 | connectedCallback() { 396 | super.connectedCallback(); 397 | this.loadData(); 398 | } 399 | 400 | render() { 401 | if (!this.hass) return html``; 402 | 403 | if (!this.tasks || !this.tags) { 404 | return html`

${localize('common.loading', this.hass.language)}

`; 405 | } 406 | 407 | return html` 408 |
409 |
410 | 411 |
412 | ${this.config?.options.sidebar_title} 413 |
414 |
415 | v${VERSION} 416 |
417 |
418 |
419 | 420 |
421 | 425 |
${this.renderForm()}
426 |
427 | 428 | 432 |
${this.renderTasks()}
433 |
434 |
435 | 436 | ${this.renderEditDialog()} 437 | `; 438 | } 439 | 440 | renderForm() { 441 | if (!this.hass) return html``; 442 | 443 | return html` 444 | this._handleFormValueChanged(e)} 451 | > 452 | 453 | (this._advancedOpen = e.detail.value)} 457 | > 458 | this._handleFormValueChanged(e)} 465 | > 466 | 467 | 468 |
469 | 470 | ${localize('panel.cards.new.actions.add_task', this.hass.language)} 471 | 472 |
473 | `; 474 | } 475 | 476 | renderTasks() { 477 | if (!this.hass) return html``; 478 | 479 | if (!this.tasks || this.tasks.length === 0) { 480 | return html`${localize('common.no_tasks', this.hass!.language)}`; 481 | } 482 | 483 | return html` 484 |
485 | 495 | 496 |
497 | `; 498 | } 499 | 500 | renderEditDialog() { 501 | if (!this.hass) return html``; 502 | 503 | if (!this._editingTaskId) return html``; 504 | 505 | return html` 506 | 511 | this._handleEditFormValueChanged(e)} 518 | > 519 | 520 | (this._editingTaskId = null)}> 521 | ${localize('panel.dialog.edit_task.actions.cancel', this.hass.language)} 522 | 523 | 524 | ${localize('panel.dialog.edit_task.actions.save', this.hass.language)} 525 | 526 | 527 | `; 528 | } 529 | 530 | private async _handleAddTaskClick() { 531 | const { title, interval_value, interval_type, last_performed, tag, icon, label } = this._formData; 532 | 533 | if (!title?.trim() || !interval_value || !interval_type) { 534 | const msg = localize("panel.cards.new.alerts.required", this.hass!.language); 535 | alert(msg); 536 | return; 537 | } 538 | 539 | const payload: Record = { 540 | title: title.trim(), 541 | interval_value, 542 | interval_type, 543 | last_performed: this.computeISODate(last_performed), 544 | tag_id: tag?.trim() || undefined, 545 | icon: icon?.trim() || "mdi:calendar-check", 546 | labels: label ?? [], 547 | }; 548 | 549 | try { 550 | await saveTask(this.hass!, payload); 551 | await this.resetForm(); 552 | } catch (error) { 553 | console.error("Failed to add task:", error); 554 | const msg = localize('panel.cards.new.alerts.error', this.hass!.language) 555 | alert(msg); 556 | } 557 | }; 558 | 559 | private async _handleCompleteTaskClick(id: string) { 560 | try { 561 | await completeTask(this.hass!, id); 562 | await this.loadData(); 563 | } catch (e) { 564 | console.error("Failed to complete task:", e); 565 | } 566 | } 567 | 568 | private async _handleOpenEditDialogClick(id: string) { 569 | try { 570 | const task: Task = await loadTask(this.hass!, id); 571 | this._editingTaskId = task.id; 572 | let labels: Label[] = []; 573 | const entity = this.registry.find((entry) => entry.unique_id === task.id); 574 | if (entity) 575 | labels = this.labelRegistry.filter((lr) => entity.labels.includes(lr.label_id)); 576 | 577 | this._editFormData = { 578 | title: task.title, 579 | interval_value: task.interval_value, 580 | interval_type: task.interval_type, 581 | last_performed: task.last_performed ?? "", 582 | icon: task.icon ?? "", 583 | label: labels.map((l) => l.label_id), 584 | tag: task.tag_id ?? "", 585 | }; 586 | 587 | await this.updateComplete; 588 | } catch (e) { 589 | console.error("Failed to fetch task for edit:", e); 590 | } 591 | } 592 | 593 | private async _handleSaveEditClick() { 594 | if (!this._editingTaskId) return; 595 | 596 | const lastPerformedISO = this.computeISODate(this._editFormData.last_performed); 597 | if (!lastPerformedISO) return; 598 | 599 | const updates: Record = { 600 | title: this._editFormData.title.trim(), 601 | interval_value: Number(this._editFormData.interval_value), 602 | interval_type: this._editFormData.interval_type, 603 | last_performed: lastPerformedISO, 604 | icon: this._editFormData.icon?.trim() || "mdi:calendar-check", 605 | labels: this._editFormData.label, 606 | }; 607 | 608 | if (this._editFormData.tag && this._editFormData.tag.trim() !== "") { 609 | updates.tag_id = this._editFormData.tag.trim(); 610 | } else { 611 | updates.tag_id = null; 612 | } 613 | 614 | const payload = { 615 | task_id: this._editingTaskId, 616 | updates, 617 | }; 618 | 619 | try { 620 | await updateTask(this.hass!, payload); 621 | this._editingTaskId = null; 622 | await this.resetEditForm(); 623 | await this.loadData(); 624 | } catch (e) { 625 | console.error("Failed to update task:", e); 626 | } 627 | } 628 | 629 | private async _handleRemoveTaskClick(id: string) { 630 | const msg = localize('panel.cards.current.confirm_remove', this.hass!.language) 631 | if (!confirm(msg)) return; 632 | try { 633 | await removeTask(this.hass!, id); 634 | await this.loadData(); 635 | } catch (e) { 636 | console.error("Failed to remove task:", e); 637 | } 638 | } 639 | 640 | private _handleDialogClosed(e: CustomEvent) { 641 | const action = e.detail?.action; 642 | if (action === "close" || action === "cancel") { 643 | this._editingTaskId = null; 644 | } 645 | } 646 | 647 | private _handleFormValueChanged(ev: CustomEvent) { 648 | this._formData = { ...this._formData, ...ev.detail.value }; 649 | } 650 | 651 | private _handleEditFormValueChanged(ev: CustomEvent) { 652 | this._editFormData = { ...this._editFormData, ...ev.detail.value }; 653 | } 654 | 655 | static styles = commonStyle; 656 | } 657 | 658 | customElements.define("home-maintenance-panel", HomeMaintenancePanel); 659 | -------------------------------------------------------------------------------- /custom_components/home_maintenance/panel/dist/main.js: -------------------------------------------------------------------------------- 1 | var V2=Object.defineProperty;var o5=Object.getOwnPropertyDescriptor;var a5=(C,H)=>{for(var V in H)V2(C,V,{get:H[V],enumerable:!0})};var h=(C,H,V,L)=>{for(var M=L>1?void 0:L?o5(H,V):H,r=C.length-1,e;r>=0;r--)(e=C[r])&&(M=(L?e(H,V,M):e(M))||M);return L&&M&&V2(H,V,M),M};var L2="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z";var M2="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z";var r2="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z";var o1=globalThis,a1=o1.ShadowRoot&&(o1.ShadyCSS===void 0||o1.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,h1=Symbol(),e2=new WeakMap,Q=class{constructor(H,V,L){if(this._$cssResult$=!0,L!==h1)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=H,this.t=V}get styleSheet(){let H=this.o,V=this.t;if(a1&&H===void 0){let L=V!==void 0&&V.length===1;L&&(H=e2.get(V)),H===void 0&&((this.o=H=new CSSStyleSheet).replaceSync(this.cssText),L&&e2.set(V,H))}return H}toString(){return this.cssText}},t2=C=>new Q(typeof C=="string"?C:C+"",void 0,h1),f1=(C,...H)=>{let V=C.length===1?C[0]:H.reduce((L,M,r)=>L+(e=>{if(e._$cssResult$===!0)return e.cssText;if(typeof e=="number")return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(M)+C[r+1],C[0]);return new Q(V,C,h1)},i2=(C,H)=>{if(a1)C.adoptedStyleSheets=H.map(V=>V instanceof CSSStyleSheet?V:V.styleSheet);else for(let V of H){let L=document.createElement("style"),M=o1.litNonce;M!==void 0&&L.setAttribute("nonce",M),L.textContent=V.cssText,C.appendChild(L)}},O1=a1?C=>C:C=>C instanceof CSSStyleSheet?(H=>{let V="";for(let L of H.cssRules)V+=L.cssText;return t2(V)})(C):C;var{is:A5,defineProperty:d5,getOwnPropertyDescriptor:m5,getOwnPropertyNames:p5,getOwnPropertySymbols:n5,getPrototypeOf:l5}=Object,y=globalThis,o2=y.trustedTypes,v5=o2?o2.emptyScript:"",x5=y.reactiveElementPolyfillSupport,$=(C,H)=>C,z={toAttribute(C,H){switch(H){case Boolean:C=C?v5:null;break;case Object:case Array:C=C==null?C:JSON.stringify(C)}return C},fromAttribute(C,H){let V=C;switch(H){case Boolean:V=C!==null;break;case Number:V=C===null?null:Number(C);break;case Object:case Array:try{V=JSON.parse(C)}catch{V=null}}return V}},A1=(C,H)=>!A5(C,H),a2={attribute:!0,type:String,converter:z,reflect:!1,useDefault:!1,hasChanged:A1};Symbol.metadata??(Symbol.metadata=Symbol("metadata")),y.litPropertyMetadata??(y.litPropertyMetadata=new WeakMap);var g=class extends HTMLElement{static addInitializer(H){this._$Ei(),(this.l??(this.l=[])).push(H)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(H,V=a2){if(V.state&&(V.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(H)&&((V=Object.create(V)).wrapped=!0),this.elementProperties.set(H,V),!V.noAccessor){let L=Symbol(),M=this.getPropertyDescriptor(H,L,V);M!==void 0&&d5(this.prototype,H,M)}}static getPropertyDescriptor(H,V,L){let{get:M,set:r}=m5(this.prototype,H)??{get(){return this[V]},set(e){this[V]=e}};return{get:M,set(e){let t=M?.call(this);r?.call(this,e),this.requestUpdate(H,t,L)},configurable:!0,enumerable:!0}}static getPropertyOptions(H){return this.elementProperties.get(H)??a2}static _$Ei(){if(this.hasOwnProperty($("elementProperties")))return;let H=l5(this);H.finalize(),H.l!==void 0&&(this.l=[...H.l]),this.elementProperties=new Map(H.elementProperties)}static finalize(){if(this.hasOwnProperty($("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty($("properties"))){let V=this.properties,L=[...p5(V),...n5(V)];for(let M of L)this.createProperty(M,V[M])}let H=this[Symbol.metadata];if(H!==null){let V=litPropertyMetadata.get(H);if(V!==void 0)for(let[L,M]of V)this.elementProperties.set(L,M)}this._$Eh=new Map;for(let[V,L]of this.elementProperties){let M=this._$Eu(V,L);M!==void 0&&this._$Eh.set(M,V)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(H){let V=[];if(Array.isArray(H)){let L=new Set(H.flat(1/0).reverse());for(let M of L)V.unshift(O1(M))}else H!==void 0&&V.push(O1(H));return V}static _$Eu(H,V){let L=V.attribute;return L===!1?void 0:typeof L=="string"?L:typeof H=="string"?H.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(H=>this.enableUpdating=H),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(H=>H(this))}addController(H){(this._$EO??(this._$EO=new Set)).add(H),this.renderRoot!==void 0&&this.isConnected&&H.hostConnected?.()}removeController(H){this._$EO?.delete(H)}_$E_(){let H=new Map,V=this.constructor.elementProperties;for(let L of V.keys())this.hasOwnProperty(L)&&(H.set(L,this[L]),delete this[L]);H.size>0&&(this._$Ep=H)}createRenderRoot(){let H=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return i2(H,this.constructor.elementStyles),H}connectedCallback(){this.renderRoot??(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),this._$EO?.forEach(H=>H.hostConnected?.())}enableUpdating(H){}disconnectedCallback(){this._$EO?.forEach(H=>H.hostDisconnected?.())}attributeChangedCallback(H,V,L){this._$AK(H,L)}_$ET(H,V){let L=this.constructor.elementProperties.get(H),M=this.constructor._$Eu(H,L);if(M!==void 0&&L.reflect===!0){let r=(L.converter?.toAttribute!==void 0?L.converter:z).toAttribute(V,L.type);this._$Em=H,r==null?this.removeAttribute(M):this.setAttribute(M,r),this._$Em=null}}_$AK(H,V){let L=this.constructor,M=L._$Eh.get(H);if(M!==void 0&&this._$Em!==M){let r=L.getPropertyOptions(M),e=typeof r.converter=="function"?{fromAttribute:r.converter}:r.converter?.fromAttribute!==void 0?r.converter:z;this._$Em=M,this[M]=e.fromAttribute(V,r.type)??this._$Ej?.get(M)??null,this._$Em=null}}requestUpdate(H,V,L){if(H!==void 0){let M=this.constructor,r=this[H];if(L??(L=M.getPropertyOptions(H)),!((L.hasChanged??A1)(r,V)||L.useDefault&&L.reflect&&r===this._$Ej?.get(H)&&!this.hasAttribute(M._$Eu(H,L))))return;this.C(H,V,L)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(H,V,{useDefault:L,reflect:M,wrapped:r},e){L&&!(this._$Ej??(this._$Ej=new Map)).has(H)&&(this._$Ej.set(H,e??V??this[H]),r!==!0||e!==void 0)||(this._$AL.has(H)||(this.hasUpdated||L||(V=void 0),this._$AL.set(H,V)),M===!0&&this._$Em!==H&&(this._$Eq??(this._$Eq=new Set)).add(H))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(V){Promise.reject(V)}let H=this.scheduleUpdate();return H!=null&&await H,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??(this.renderRoot=this.createRenderRoot()),this._$Ep){for(let[M,r]of this._$Ep)this[M]=r;this._$Ep=void 0}let L=this.constructor.elementProperties;if(L.size>0)for(let[M,r]of L){let{wrapped:e}=r,t=this[M];e!==!0||this._$AL.has(M)||t===void 0||this.C(M,void 0,r,t)}}let H=!1,V=this._$AL;try{H=this.shouldUpdate(V),H?(this.willUpdate(V),this._$EO?.forEach(L=>L.hostUpdate?.()),this.update(V)):this._$EM()}catch(L){throw H=!1,this._$EM(),L}H&&this._$AE(V)}willUpdate(H){}_$AE(H){this._$EO?.forEach(V=>V.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(H)),this.updated(H)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(H){return!0}update(H){this._$Eq&&(this._$Eq=this._$Eq.forEach(V=>this._$ET(V,this[V]))),this._$EM()}updated(H){}firstUpdated(H){}};g.elementStyles=[],g.shadowRootOptions={mode:"open"},g[$("elementProperties")]=new Map,g[$("finalized")]=new Map,x5?.({ReactiveElement:g}),(y.reactiveElementVersions??(y.reactiveElementVersions=[])).push("2.1.0");var q=globalThis,d1=q.trustedTypes,A2=d1?d1.createPolicy("lit-html",{createHTML:C=>C}):void 0,v2="$lit$",k=`lit$${Math.random().toFixed(9).slice(2)}$`,x2="?"+k,Z5=`<${x2}>`,E=document,K=()=>E.createComment(""),X=C=>C===null||typeof C!="object"&&typeof C!="function",T1=Array.isArray,u5=C=>T1(C)||typeof C?.[Symbol.iterator]=="function",g1=`[ 2 | \f\r]`,j=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,d2=/-->/g,m2=/>/g,P=RegExp(`>|${g1}(?:([^\\s"'>=/]+)(${g1}*=${g1}*(?:[^ 3 | \f\r"'\`<>=]|("|')|))|$)`,"g"),p2=/'/g,n2=/"/g,Z2=/^(?:script|style|textarea|title)$/i,P1=C=>(H,...V)=>({_$litType$:C,strings:H,values:V}),u=P1(1),p3=P1(2),n3=P1(3),R=Symbol.for("lit-noChange"),Z=Symbol.for("lit-nothing"),l2=new WeakMap,F=E.createTreeWalker(E,129);function u2(C,H){if(!T1(C)||!C.hasOwnProperty("raw"))throw Error("invalid template strings array");return A2!==void 0?A2.createHTML(H):H}var s5=(C,H)=>{let V=C.length-1,L=[],M,r=H===2?"":H===3?"":"",e=j;for(let t=0;t"?(e=M??j,A=-1):i[1]===void 0?A=-2:(A=e.lastIndex-i[2].length,a=i[1],e=i[3]===void 0?P:i[3]==='"'?n2:p2):e===n2||e===p2?e=P:e===d2||e===m2?e=j:(e=P,M=void 0);let l=e===P&&C[t+1].startsWith("/>")?" ":"";r+=e===j?o+Z5:A>=0?(L.push(a),o.slice(0,A)+v2+o.slice(A)+k+l):o+k+(A===-2?t:l)}return[u2(C,r+(C[V]||"")+(H===2?"":H===3?"":"")),L]},Y=class C{constructor({strings:H,_$litType$:V},L){let M;this.parts=[];let r=0,e=0,t=H.length-1,o=this.parts,[a,i]=s5(H,V);if(this.el=C.createElement(a,L),F.currentNode=this.el.content,V===2||V===3){let A=this.el.content.firstChild;A.replaceWith(...A.childNodes)}for(;(M=F.nextNode())!==null&&o.length0){M.textContent=d1?d1.emptyScript:"";for(let l=0;l2||L[0]!==""||L[1]!==""?(this._$AH=Array(L.length-1).fill(new String),this.strings=L):this._$AH=Z}_$AI(H,V=this,L,M){let r=this.strings,e=!1;if(r===void 0)H=I(this,H,V,0),e=!X(H)||H!==this._$AH&&H!==R,e&&(this._$AH=H);else{let t=H,o,a;for(H=r[0],o=0;o{let L=V?.renderBefore??H,M=L._$litPart$;if(M===void 0){let r=V?.renderBefore??null;L._$litPart$=M=new J(H.insertBefore(K(),r),r,void 0,V??{})}return M._$AI(C),M};var C1=globalThis,B=class extends g{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){var V;let H=super.createRenderRoot();return(V=this.renderOptions).renderBefore??(V.renderBefore=H.firstChild),H}update(H){let V=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(H),this._$Do=s2(V,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return R}};B._$litElement$=!0,B.finalized=!0,C1.litElementHydrateSupport?.({LitElement:B});var c5=C1.litElementPolyfillSupport;c5?.({LitElement:B});(C1.litElementVersions??(C1.litElementVersions=[])).push("4.2.0");var h5={attribute:!0,type:String,converter:z,reflect:!1,hasChanged:A1},f5=(C=h5,H,V)=>{let{kind:L,metadata:M}=V,r=globalThis.litPropertyMetadata.get(M);if(r===void 0&&globalThis.litPropertyMetadata.set(M,r=new Map),L==="setter"&&((C=Object.create(C)).wrapped=!0),r.set(V.name,C),L==="accessor"){let{name:e}=V;return{set(t){let o=H.get.call(this);H.set.call(this,t),this.requestUpdate(e,o,C)},init(t){return t!==void 0&&this.C(e,void 0,C,t),t}}}if(L==="setter"){let{name:e}=V;return function(t){let o=this[e];H.call(this,t),this.requestUpdate(e,o,C)}}throw Error("Unsupported decorator location: "+L)};function H1(C){return(H,V)=>typeof V=="object"?f5(C,H,V):((L,M,r)=>{let e=M.hasOwnProperty(r);return M.constructor.createProperty(r,L),e?Object.getOwnPropertyDescriptor(M,r):void 0})(C,H,V)}function b(C){return H1({...C,state:!0,attribute:!1})}var S2,c2;var F1=function(C,H){return O5(H).format(C)},O5=function(C){return new Intl.DateTimeFormat(C.language,{year:"numeric",month:"numeric",day:"numeric"})};(function(C){C.language="language",C.system="system",C.comma_decimal="comma_decimal",C.decimal_comma="decimal_comma",C.space_comma="space_comma",C.none="none"})(S2||(S2={})),function(C){C.language="language",C.system="system",C.am_pm="12",C.twenty_four="24"}(c2||(c2={}));var E1={};a5(E1,{common:()=>g5,default:()=>k5,intervals:()=>b5,panel:()=>y5});var g5={loading:"Loading...",none:"None",no_tasks:"No tasks found."},b5={day:"Day",days:"Days",week:"Week",weeks:"Weeks",month:"Month",months:"Months"},y5={cards:{new:{title:"Create New Task",fields:{title:{heading:"Task Title"},interval_value:{heading:"Interval"},interval_type:{heading:"Interval Type"},last_performed:{heading:"Last Performed",helper:"Leave blank to use today"},tag:{heading:"Tag"},icon:{heading:"Icon"},label:{heading:"Label(s)"}},sections:{optional:"Optional settings"},actions:{add_task:"Add Task"},alerts:{required:"Please fill all fields",error:"Error adding task. See console for details."}},current:{title:"Current Tasks",no_items:"No tasks found.",every:"every",last:"Last",next:"Next Due",actions:{complete:"Complete",edit:"Edit",remove:"Remove"},confirm_remove:"Are you sure you want to remove this task?"}},dialog:{edit_task:{title:"Edit Task",fields:{interval_value:{heading:"Interval"},interval_type:{heading:"Interval Type"},last_performed:{heading:"Last Performed",helper:"Leave blank to use today"},tag:{heading:"Tag"},icon:{heading:"Icon"},label:{heading:"Label(s)"}},sections:{optional:"Optional settings"},actions:{cancel:"Cancel",save:"Save"}}}},k5={common:g5,intervals:b5,panel:y5};var R1=function(C,H){return R1=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(V,L){V.__proto__=L}||function(V,L){for(var M in L)Object.prototype.hasOwnProperty.call(L,M)&&(V[M]=L[M])},R1(C,H)};function V1(C,H){if(typeof H!="function"&&H!==null)throw new TypeError("Class extends value "+String(H)+" is not a constructor or null");R1(C,H);function V(){this.constructor=C}C.prototype=H===null?Object.create(H):(V.prototype=H.prototype,new V)}var p=function(){return p=Object.assign||function(H){for(var V,L=1,M=arguments.length;L0}),V=[],L=0,M=H;L1)throw new RangeError("integer-width stems only accept a single optional option");M.options[0].replace(T5,function(o,a,i,A,d,l){if(a)H.minimumIntegerDigits=i.length;else{if(A&&d)throw new Error("We currently do not support maximum integer digits");if(l)throw new Error("We currently do not support exact integer digits")}return""});continue}if(T2.test(M.stem)){H.minimumIntegerDigits=M.stem.length;continue}if(b2.test(M.stem)){if(M.options.length>1)throw new RangeError("Fraction-precision stems only accept a single optional option");M.stem.replace(b2,function(o,a,i,A,d,l){return i==="*"?H.minimumFractionDigits=a.length:A&&A[0]==="#"?H.maximumFractionDigits=A.length:d&&l?(H.minimumFractionDigits=d.length,H.maximumFractionDigits=d.length+l.length):(H.minimumFractionDigits=a.length,H.maximumFractionDigits=a.length),""});var r=M.options[0];r==="w"?H=p(p({},H),{trailingZeroDisplay:"stripIfInteger"}):r&&(H=p(p({},H),y2(r)));continue}if(w2.test(M.stem)){H=p(p({},H),y2(M.stem));continue}var e=P2(M.stem);e&&(H=p(p({},H),e));var t=P5(M.stem);t&&(H=p(p({},H),t))}return H}var M1={AX:["H"],BQ:["H"],CP:["H"],CZ:["H"],DK:["H"],FI:["H"],ID:["H"],IS:["H"],ML:["H"],NE:["H"],RU:["H"],SE:["H"],SJ:["H"],SK:["H"],AS:["h","H"],BT:["h","H"],DJ:["h","H"],ER:["h","H"],GH:["h","H"],IN:["h","H"],LS:["h","H"],PG:["h","H"],PW:["h","H"],SO:["h","H"],TO:["h","H"],VU:["h","H"],WS:["h","H"],"001":["H","h"],AL:["h","H","hB"],TD:["h","H","hB"],"ca-ES":["H","h","hB"],CF:["H","h","hB"],CM:["H","h","hB"],"fr-CA":["H","h","hB"],"gl-ES":["H","h","hB"],"it-CH":["H","h","hB"],"it-IT":["H","h","hB"],LU:["H","h","hB"],NP:["H","h","hB"],PF:["H","h","hB"],SC:["H","h","hB"],SM:["H","h","hB"],SN:["H","h","hB"],TF:["H","h","hB"],VA:["H","h","hB"],CY:["h","H","hb","hB"],GR:["h","H","hb","hB"],CO:["h","H","hB","hb"],DO:["h","H","hB","hb"],KP:["h","H","hB","hb"],KR:["h","H","hB","hb"],NA:["h","H","hB","hb"],PA:["h","H","hB","hb"],PR:["h","H","hB","hb"],VE:["h","H","hB","hb"],AC:["H","h","hb","hB"],AI:["H","h","hb","hB"],BW:["H","h","hb","hB"],BZ:["H","h","hb","hB"],CC:["H","h","hb","hB"],CK:["H","h","hb","hB"],CX:["H","h","hb","hB"],DG:["H","h","hb","hB"],FK:["H","h","hb","hB"],GB:["H","h","hb","hB"],GG:["H","h","hb","hB"],GI:["H","h","hb","hB"],IE:["H","h","hb","hB"],IM:["H","h","hb","hB"],IO:["H","h","hb","hB"],JE:["H","h","hb","hB"],LT:["H","h","hb","hB"],MK:["H","h","hb","hB"],MN:["H","h","hb","hB"],MS:["H","h","hb","hB"],NF:["H","h","hb","hB"],NG:["H","h","hb","hB"],NR:["H","h","hb","hB"],NU:["H","h","hb","hB"],PN:["H","h","hb","hB"],SH:["H","h","hb","hB"],SX:["H","h","hb","hB"],TA:["H","h","hb","hB"],ZA:["H","h","hb","hB"],"af-ZA":["H","h","hB","hb"],AR:["H","h","hB","hb"],CL:["H","h","hB","hb"],CR:["H","h","hB","hb"],CU:["H","h","hB","hb"],EA:["H","h","hB","hb"],"es-BO":["H","h","hB","hb"],"es-BR":["H","h","hB","hb"],"es-EC":["H","h","hB","hb"],"es-ES":["H","h","hB","hb"],"es-GQ":["H","h","hB","hb"],"es-PE":["H","h","hB","hb"],GT:["H","h","hB","hb"],HN:["H","h","hB","hb"],IC:["H","h","hB","hb"],KG:["H","h","hB","hb"],KM:["H","h","hB","hb"],LK:["H","h","hB","hb"],MA:["H","h","hB","hb"],MX:["H","h","hB","hb"],NI:["H","h","hB","hb"],PY:["H","h","hB","hb"],SV:["H","h","hB","hb"],UY:["H","h","hB","hb"],JP:["H","h","K"],AD:["H","hB"],AM:["H","hB"],AO:["H","hB"],AT:["H","hB"],AW:["H","hB"],BE:["H","hB"],BF:["H","hB"],BJ:["H","hB"],BL:["H","hB"],BR:["H","hB"],CG:["H","hB"],CI:["H","hB"],CV:["H","hB"],DE:["H","hB"],EE:["H","hB"],FR:["H","hB"],GA:["H","hB"],GF:["H","hB"],GN:["H","hB"],GP:["H","hB"],GW:["H","hB"],HR:["H","hB"],IL:["H","hB"],IT:["H","hB"],KZ:["H","hB"],MC:["H","hB"],MD:["H","hB"],MF:["H","hB"],MQ:["H","hB"],MZ:["H","hB"],NC:["H","hB"],NL:["H","hB"],PM:["H","hB"],PT:["H","hB"],RE:["H","hB"],RO:["H","hB"],SI:["H","hB"],SR:["H","hB"],ST:["H","hB"],TG:["H","hB"],TR:["H","hB"],WF:["H","hB"],YT:["H","hB"],BD:["h","hB","H"],PK:["h","hB","H"],AZ:["H","hB","h"],BA:["H","hB","h"],BG:["H","hB","h"],CH:["H","hB","h"],GE:["H","hB","h"],LI:["H","hB","h"],ME:["H","hB","h"],RS:["H","hB","h"],UA:["H","hB","h"],UZ:["H","hB","h"],XK:["H","hB","h"],AG:["h","hb","H","hB"],AU:["h","hb","H","hB"],BB:["h","hb","H","hB"],BM:["h","hb","H","hB"],BS:["h","hb","H","hB"],CA:["h","hb","H","hB"],DM:["h","hb","H","hB"],"en-001":["h","hb","H","hB"],FJ:["h","hb","H","hB"],FM:["h","hb","H","hB"],GD:["h","hb","H","hB"],GM:["h","hb","H","hB"],GU:["h","hb","H","hB"],GY:["h","hb","H","hB"],JM:["h","hb","H","hB"],KI:["h","hb","H","hB"],KN:["h","hb","H","hB"],KY:["h","hb","H","hB"],LC:["h","hb","H","hB"],LR:["h","hb","H","hB"],MH:["h","hb","H","hB"],MP:["h","hb","H","hB"],MW:["h","hb","H","hB"],NZ:["h","hb","H","hB"],SB:["h","hb","H","hB"],SG:["h","hb","H","hB"],SL:["h","hb","H","hB"],SS:["h","hb","H","hB"],SZ:["h","hb","H","hB"],TC:["h","hb","H","hB"],TT:["h","hb","H","hB"],UM:["h","hb","H","hB"],US:["h","hb","H","hB"],VC:["h","hb","H","hB"],VG:["h","hb","H","hB"],VI:["h","hb","H","hB"],ZM:["h","hb","H","hB"],BO:["H","hB","h","hb"],EC:["H","hB","h","hb"],ES:["H","hB","h","hb"],GQ:["H","hB","h","hb"],PE:["H","hB","h","hb"],AE:["h","hB","hb","H"],"ar-001":["h","hB","hb","H"],BH:["h","hB","hb","H"],DZ:["h","hB","hb","H"],EG:["h","hB","hb","H"],EH:["h","hB","hb","H"],HK:["h","hB","hb","H"],IQ:["h","hB","hb","H"],JO:["h","hB","hb","H"],KW:["h","hB","hb","H"],LB:["h","hB","hb","H"],LY:["h","hB","hb","H"],MO:["h","hB","hb","H"],MR:["h","hB","hb","H"],OM:["h","hB","hb","H"],PH:["h","hB","hb","H"],PS:["h","hB","hb","H"],QA:["h","hB","hb","H"],SA:["h","hB","hb","H"],SD:["h","hB","hb","H"],SY:["h","hB","hb","H"],TN:["h","hB","hb","H"],YE:["h","hB","hb","H"],AF:["H","hb","hB","h"],LA:["H","hb","hB","h"],CN:["H","hB","hb","h"],LV:["H","hB","hb","h"],TL:["H","hB","hb","h"],"zu-ZA":["H","hB","hb","h"],CD:["hB","H"],IR:["hB","H"],"hi-IN":["hB","h","H"],"kn-IN":["hB","h","H"],"ml-IN":["hB","h","H"],"te-IN":["hB","h","H"],KH:["hB","h","H","hb"],"ta-IN":["hB","h","hb","H"],BN:["hb","hB","h","H"],MY:["hb","hB","h","H"],ET:["hB","hb","h","H"],"gu-IN":["hB","hb","h","H"],"mr-IN":["hB","hb","h","H"],"pa-IN":["hB","hb","h","H"],TW:["hB","hb","h","H"],KE:["hB","hb","H","h"],MM:["hB","hb","H","h"],TZ:["hB","hb","H","h"],UG:["hB","hb","H","h"]};function E2(C,H){for(var V="",L=0;L>1),o="a",a=F5(H);for((a=="H"||a=="k")&&(t=0);t-- >0;)V+=o;for(;e-- >0;)V=a+V}else M==="J"?V+="H":V+=M}return V}function F5(C){var H=C.hourCycle;if(H===void 0&&C.hourCycles&&C.hourCycles.length&&(H=C.hourCycles[0]),H)switch(H){case"h24":return"k";case"h23":return"H";case"h12":return"h";case"h11":return"K";default:throw new Error("Invalid hourCycle")}var V=C.language,L;V!=="root"&&(L=C.maximize().region);var M=M1[L||""]||M1[V||""]||M1["".concat(V,"-001")]||M1["001"];return M[0]}var N1,E5=new RegExp("^".concat(D1.source,"*")),R5=new RegExp("".concat(D1.source,"*$"));function n(C,H){return{start:C,end:H}}var _5=!!String.prototype.startsWith,D5=!!String.fromCodePoint,N5=!!Object.fromEntries,I5=!!String.prototype.codePointAt,W5=!!String.prototype.trimStart,U5=!!String.prototype.trimEnd,G5=!!Number.isSafeInteger,Q5=G5?Number.isSafeInteger:function(C){return typeof C=="number"&&isFinite(C)&&Math.floor(C)===C&&Math.abs(C)<=9007199254740991},W1=!0;try{R2=I2("([^\\p{White_Space}\\p{Pattern_Syntax}]*)","yu"),W1=((N1=R2.exec("a"))===null||N1===void 0?void 0:N1[0])==="a"}catch{W1=!1}var R2,_2=_5?function(H,V,L){return H.startsWith(V,L)}:function(H,V,L){return H.slice(L,L+V.length)===V},U1=D5?String.fromCodePoint:function(){for(var H=[],V=0;Vr;){if(e=H[r++],e>1114111)throw RangeError(e+" is not a valid code point");L+=e<65536?String.fromCharCode(e):String.fromCharCode(((e-=65536)>>10)+55296,e%1024+56320)}return L},D2=N5?Object.fromEntries:function(H){for(var V={},L=0,M=H;L=L)){var M=H.charCodeAt(V),r;return M<55296||M>56319||V+1===L||(r=H.charCodeAt(V+1))<56320||r>57343?M:(M-55296<<10)+(r-56320)+65536}},$5=W5?function(H){return H.trimStart()}:function(H){return H.replace(E5,"")},z5=U5?function(H){return H.trimEnd()}:function(H){return H.replace(R5,"")};function I2(C,H){return new RegExp(C,H)}var G1;W1?(I1=I2("([^\\p{White_Space}\\p{Pattern_Syntax}]*)","yu"),G1=function(H,V){var L;I1.lastIndex=V;var M=I1.exec(H);return(L=M[1])!==null&&L!==void 0?L:""}):G1=function(H,V){for(var L=[];;){var M=N2(H,V);if(M===void 0||U2(M)||K5(M))break;L.push(M),V+=M>=65536?2:1}return U1.apply(void 0,L)};var I1,W2=function(){function C(H,V){V===void 0&&(V={}),this.message=H,this.position={offset:0,line:1,column:1},this.ignoreTag=!!V.ignoreTag,this.locale=V.locale,this.requiresOtherClause=!!V.requiresOtherClause,this.shouldParseSkeletons=!!V.shouldParseSkeletons}return C.prototype.parse=function(){if(this.offset()!==0)throw Error("parser can only be used once");return this.parseMessage(0,"",!1)},C.prototype.parseMessage=function(H,V,L){for(var M=[];!this.isEOF();){var r=this.char();if(r===123){var e=this.parseArgument(H,L);if(e.err)return e;M.push(e.val)}else{if(r===125&&H>0)break;if(r===35&&(V==="plural"||V==="selectordinal")){var t=this.clonePosition();this.bump(),M.push({type:x.pound,location:n(t,this.clonePosition())})}else if(r===60&&!this.ignoreTag&&this.peek()===47){if(L)break;return this.error(m.UNMATCHED_CLOSING_TAG,n(this.clonePosition(),this.clonePosition()))}else if(r===60&&!this.ignoreTag&&Q1(this.peek()||0)){var e=this.parseTag(H,V);if(e.err)return e;M.push(e.val)}else{var e=this.parseLiteral(H,V);if(e.err)return e;M.push(e.val)}}}return{val:M,err:null}},C.prototype.parseTag=function(H,V){var L=this.clonePosition();this.bump();var M=this.parseTagName();if(this.bumpSpace(),this.bumpIf("/>"))return{val:{type:x.literal,value:"<".concat(M,"/>"),location:n(L,this.clonePosition())},err:null};if(this.bumpIf(">")){var r=this.parseMessage(H+1,V,!0);if(r.err)return r;var e=r.val,t=this.clonePosition();if(this.bumpIf("")?{val:{type:x.tag,value:M,children:e,location:n(L,this.clonePosition())},err:null}:this.error(m.INVALID_TAG,n(t,this.clonePosition())))}else return this.error(m.UNCLOSED_TAG,n(L,this.clonePosition()))}else return this.error(m.INVALID_TAG,n(L,this.clonePosition()))},C.prototype.parseTagName=function(){var H=this.offset();for(this.bump();!this.isEOF()&&q5(this.char());)this.bump();return this.message.slice(H,this.offset())},C.prototype.parseLiteral=function(H,V){for(var L=this.clonePosition(),M="";;){var r=this.tryParseQuote(V);if(r){M+=r;continue}var e=this.tryParseUnquoted(H,V);if(e){M+=e;continue}var t=this.tryParseLeftAngleBracket();if(t){M+=t;continue}break}var o=n(L,this.clonePosition());return{val:{type:x.literal,value:M,location:o},err:null}},C.prototype.tryParseLeftAngleBracket=function(){return!this.isEOF()&&this.char()===60&&(this.ignoreTag||!j5(this.peek()||0))?(this.bump(),"<"):null},C.prototype.tryParseQuote=function(H){if(this.isEOF()||this.char()!==39)return null;switch(this.peek()){case 39:return this.bump(),this.bump(),"'";case 123:case 60:case 62:case 125:break;case 35:if(H==="plural"||H==="selectordinal")break;return null;default:return null}this.bump();var V=[this.char()];for(this.bump();!this.isEOF();){var L=this.char();if(L===39)if(this.peek()===39)V.push(39),this.bump();else{this.bump();break}else V.push(L);this.bump()}return U1.apply(void 0,V)},C.prototype.tryParseUnquoted=function(H,V){if(this.isEOF())return null;var L=this.char();return L===60||L===123||L===35&&(V==="plural"||V==="selectordinal")||L===125&&H>0?null:(this.bump(),U1(L))},C.prototype.parseArgument=function(H,V){var L=this.clonePosition();if(this.bump(),this.bumpSpace(),this.isEOF())return this.error(m.EXPECT_ARGUMENT_CLOSING_BRACE,n(L,this.clonePosition()));if(this.char()===125)return this.bump(),this.error(m.EMPTY_ARGUMENT,n(L,this.clonePosition()));var M=this.parseIdentifierIfPossible().value;if(!M)return this.error(m.MALFORMED_ARGUMENT,n(L,this.clonePosition()));if(this.bumpSpace(),this.isEOF())return this.error(m.EXPECT_ARGUMENT_CLOSING_BRACE,n(L,this.clonePosition()));switch(this.char()){case 125:return this.bump(),{val:{type:x.argument,value:M,location:n(L,this.clonePosition())},err:null};case 44:return this.bump(),this.bumpSpace(),this.isEOF()?this.error(m.EXPECT_ARGUMENT_CLOSING_BRACE,n(L,this.clonePosition())):this.parseArgumentOptions(H,V,M,L);default:return this.error(m.MALFORMED_ARGUMENT,n(L,this.clonePosition()))}},C.prototype.parseIdentifierIfPossible=function(){var H=this.clonePosition(),V=this.offset(),L=G1(this.message,V),M=V+L.length;this.bumpTo(M);var r=this.clonePosition(),e=n(H,r);return{value:L,location:e}},C.prototype.parseArgumentOptions=function(H,V,L,M){var r,e=this.clonePosition(),t=this.parseIdentifierIfPossible().value,o=this.clonePosition();switch(t){case"":return this.error(m.EXPECT_ARGUMENT_TYPE,n(e,o));case"number":case"date":case"time":{this.bumpSpace();var a=null;if(this.bumpIf(",")){this.bumpSpace();var i=this.clonePosition(),A=this.parseSimpleArgStyleIfPossible();if(A.err)return A;var d=z5(A.val);if(d.length===0)return this.error(m.EXPECT_ARGUMENT_STYLE,n(this.clonePosition(),this.clonePosition()));var l=n(i,this.clonePosition());a={style:d,styleLocation:l}}var s=this.tryParseArgumentClose(M);if(s.err)return s;var f=n(M,this.clonePosition());if(a&&_2(a?.style,"::",0)){var w=$5(a.style.slice(2));if(t==="number"){var A=this.parseNumberSkeletonFromString(w,a.styleLocation);return A.err?A:{val:{type:x.number,value:L,location:f,style:A.val},err:null}}else{if(w.length===0)return this.error(m.EXPECT_DATE_TIME_SKELETON,f);var U=w;this.locale&&(U=E2(w,this.locale));var d={type:_.dateTime,pattern:U,location:a.styleLocation,parsedOptions:this.shouldParseSkeletons?O2(U):{}},N=t==="date"?x.date:x.time;return{val:{type:N,value:L,location:f,style:d},err:null}}}return{val:{type:t==="number"?x.number:t==="date"?x.date:x.time,value:L,location:f,style:(r=a?.style)!==null&&r!==void 0?r:null},err:null}}case"plural":case"selectordinal":case"select":{var O=this.clonePosition();if(this.bumpSpace(),!this.bumpIf(","))return this.error(m.EXPECT_SELECT_ARGUMENT_OPTIONS,n(O,p({},O)));this.bumpSpace();var G=this.parseIdentifierIfPossible(),T=0;if(t!=="select"&&G.value==="offset"){if(!this.bumpIf(":"))return this.error(m.EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE,n(this.clonePosition(),this.clonePosition()));this.bumpSpace();var A=this.tryParseDecimalInteger(m.EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE,m.INVALID_PLURAL_ARGUMENT_OFFSET_VALUE);if(A.err)return A;this.bumpSpace(),G=this.parseIdentifierIfPossible(),T=A.val}var i1=this.tryParsePluralOrSelectOptions(H,t,V,G);if(i1.err)return i1;var s=this.tryParseArgumentClose(M);if(s.err)return s;var H2=n(M,this.clonePosition());return t==="select"?{val:{type:x.select,value:L,options:D2(i1.val),location:H2},err:null}:{val:{type:x.plural,value:L,options:D2(i1.val),offset:T,pluralType:t==="plural"?"cardinal":"ordinal",location:H2},err:null}}default:return this.error(m.INVALID_ARGUMENT_TYPE,n(e,o))}},C.prototype.tryParseArgumentClose=function(H){return this.isEOF()||this.char()!==125?this.error(m.EXPECT_ARGUMENT_CLOSING_BRACE,n(H,this.clonePosition())):(this.bump(),{val:!0,err:null})},C.prototype.parseSimpleArgStyleIfPossible=function(){for(var H=0,V=this.clonePosition();!this.isEOF();){var L=this.char();switch(L){case 39:{this.bump();var M=this.clonePosition();if(!this.bumpUntil("'"))return this.error(m.UNCLOSED_QUOTE_IN_ARGUMENT_STYLE,n(M,this.clonePosition()));this.bump();break}case 123:{H+=1,this.bump();break}case 125:{if(H>0)H-=1;else return{val:this.message.slice(V.offset,this.offset()),err:null};break}default:this.bump();break}}return{val:this.message.slice(V.offset,this.offset()),err:null}},C.prototype.parseNumberSkeletonFromString=function(H,V){var L=[];try{L=B2(H)}catch{return this.error(m.INVALID_NUMBER_SKELETON,V)}return{val:{type:_.number,tokens:L,location:V,parsedOptions:this.shouldParseSkeletons?F2(L):{}},err:null}},C.prototype.tryParsePluralOrSelectOptions=function(H,V,L,M){for(var r,e=!1,t=[],o=new Set,a=M.value,i=M.location;;){if(a.length===0){var A=this.clonePosition();if(V!=="select"&&this.bumpIf("=")){var d=this.tryParseDecimalInteger(m.EXPECT_PLURAL_ARGUMENT_SELECTOR,m.INVALID_PLURAL_ARGUMENT_SELECTOR);if(d.err)return d;i=n(A,this.clonePosition()),a=this.message.slice(A.offset,this.offset())}else break}if(o.has(a))return this.error(V==="select"?m.DUPLICATE_SELECT_ARGUMENT_SELECTOR:m.DUPLICATE_PLURAL_ARGUMENT_SELECTOR,i);a==="other"&&(e=!0),this.bumpSpace();var l=this.clonePosition();if(!this.bumpIf("{"))return this.error(V==="select"?m.EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT:m.EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT,n(this.clonePosition(),this.clonePosition()));var s=this.parseMessage(H+1,V,L);if(s.err)return s;var f=this.tryParseArgumentClose(l);if(f.err)return f;t.push([a,{value:s.val,location:n(l,this.clonePosition())}]),o.add(a),this.bumpSpace(),r=this.parseIdentifierIfPossible(),a=r.value,i=r.location}return t.length===0?this.error(V==="select"?m.EXPECT_SELECT_ARGUMENT_SELECTOR:m.EXPECT_PLURAL_ARGUMENT_SELECTOR,n(this.clonePosition(),this.clonePosition())):this.requiresOtherClause&&!e?this.error(m.MISSING_OTHER_CLAUSE,n(this.clonePosition(),this.clonePosition())):{val:t,err:null}},C.prototype.tryParseDecimalInteger=function(H,V){var L=1,M=this.clonePosition();this.bumpIf("+")||this.bumpIf("-")&&(L=-1);for(var r=!1,e=0;!this.isEOF();){var t=this.char();if(t>=48&&t<=57)r=!0,e=e*10+(t-48),this.bump();else break}var o=n(M,this.clonePosition());return r?(e*=L,Q5(e)?{val:e,err:null}:this.error(V,o)):this.error(H,o)},C.prototype.offset=function(){return this.position.offset},C.prototype.isEOF=function(){return this.offset()===this.message.length},C.prototype.clonePosition=function(){return{offset:this.position.offset,line:this.position.line,column:this.position.column}},C.prototype.char=function(){var H=this.position.offset;if(H>=this.message.length)throw Error("out of bound");var V=N2(this.message,H);if(V===void 0)throw Error("Offset ".concat(H," is at invalid UTF-16 code unit boundary"));return V},C.prototype.error=function(H,V){return{val:null,err:{kind:H,message:this.message,location:V}}},C.prototype.bump=function(){if(!this.isEOF()){var H=this.char();H===10?(this.position.line+=1,this.position.column=1,this.position.offset+=1):(this.position.column+=1,this.position.offset+=H<65536?1:2)}},C.prototype.bumpIf=function(H){if(_2(this.message,H,this.offset())){for(var V=0;V=0?(this.bumpTo(L),!0):(this.bumpTo(this.message.length),!1)},C.prototype.bumpTo=function(H){if(this.offset()>H)throw Error("targetOffset ".concat(H," must be greater than or equal to the current offset ").concat(this.offset()));for(H=Math.min(H,this.message.length);;){var V=this.offset();if(V===H)break;if(V>H)throw Error("targetOffset ".concat(H," is at invalid UTF-16 code unit boundary"));if(this.bump(),this.isEOF())break}},C.prototype.bumpSpace=function(){for(;!this.isEOF()&&U2(this.char());)this.bump()},C.prototype.peek=function(){if(this.isEOF())return null;var H=this.char(),V=this.offset(),L=this.message.charCodeAt(V+(H>=65536?2:1));return L??null},C}();function Q1(C){return C>=97&&C<=122||C>=65&&C<=90}function j5(C){return Q1(C)||C===47}function q5(C){return C===45||C===46||C>=48&&C<=57||C===95||C>=97&&C<=122||C>=65&&C<=90||C==183||C>=192&&C<=214||C>=216&&C<=246||C>=248&&C<=893||C>=895&&C<=8191||C>=8204&&C<=8205||C>=8255&&C<=8256||C>=8304&&C<=8591||C>=11264&&C<=12271||C>=12289&&C<=55295||C>=63744&&C<=64975||C>=65008&&C<=65533||C>=65536&&C<=983039}function U2(C){return C>=9&&C<=13||C===32||C===133||C>=8206&&C<=8207||C===8232||C===8233}function K5(C){return C>=33&&C<=35||C===36||C>=37&&C<=39||C===40||C===41||C===42||C===43||C===44||C===45||C>=46&&C<=47||C>=58&&C<=59||C>=60&&C<=62||C>=63&&C<=64||C===91||C===92||C===93||C===94||C===96||C===123||C===124||C===125||C===126||C===161||C>=162&&C<=165||C===166||C===167||C===169||C===171||C===172||C===174||C===176||C===177||C===182||C===187||C===191||C===215||C===247||C>=8208&&C<=8213||C>=8214&&C<=8215||C===8216||C===8217||C===8218||C>=8219&&C<=8220||C===8221||C===8222||C===8223||C>=8224&&C<=8231||C>=8240&&C<=8248||C===8249||C===8250||C>=8251&&C<=8254||C>=8257&&C<=8259||C===8260||C===8261||C===8262||C>=8263&&C<=8273||C===8274||C===8275||C>=8277&&C<=8286||C>=8592&&C<=8596||C>=8597&&C<=8601||C>=8602&&C<=8603||C>=8604&&C<=8607||C===8608||C>=8609&&C<=8610||C===8611||C>=8612&&C<=8613||C===8614||C>=8615&&C<=8621||C===8622||C>=8623&&C<=8653||C>=8654&&C<=8655||C>=8656&&C<=8657||C===8658||C===8659||C===8660||C>=8661&&C<=8691||C>=8692&&C<=8959||C>=8960&&C<=8967||C===8968||C===8969||C===8970||C===8971||C>=8972&&C<=8991||C>=8992&&C<=8993||C>=8994&&C<=9e3||C===9001||C===9002||C>=9003&&C<=9083||C===9084||C>=9085&&C<=9114||C>=9115&&C<=9139||C>=9140&&C<=9179||C>=9180&&C<=9185||C>=9186&&C<=9254||C>=9255&&C<=9279||C>=9280&&C<=9290||C>=9291&&C<=9311||C>=9472&&C<=9654||C===9655||C>=9656&&C<=9664||C===9665||C>=9666&&C<=9719||C>=9720&&C<=9727||C>=9728&&C<=9838||C===9839||C>=9840&&C<=10087||C===10088||C===10089||C===10090||C===10091||C===10092||C===10093||C===10094||C===10095||C===10096||C===10097||C===10098||C===10099||C===10100||C===10101||C>=10132&&C<=10175||C>=10176&&C<=10180||C===10181||C===10182||C>=10183&&C<=10213||C===10214||C===10215||C===10216||C===10217||C===10218||C===10219||C===10220||C===10221||C===10222||C===10223||C>=10224&&C<=10239||C>=10240&&C<=10495||C>=10496&&C<=10626||C===10627||C===10628||C===10629||C===10630||C===10631||C===10632||C===10633||C===10634||C===10635||C===10636||C===10637||C===10638||C===10639||C===10640||C===10641||C===10642||C===10643||C===10644||C===10645||C===10646||C===10647||C===10648||C>=10649&&C<=10711||C===10712||C===10713||C===10714||C===10715||C>=10716&&C<=10747||C===10748||C===10749||C>=10750&&C<=11007||C>=11008&&C<=11055||C>=11056&&C<=11076||C>=11077&&C<=11078||C>=11079&&C<=11084||C>=11085&&C<=11123||C>=11124&&C<=11125||C>=11126&&C<=11157||C===11158||C>=11159&&C<=11263||C>=11776&&C<=11777||C===11778||C===11779||C===11780||C===11781||C>=11782&&C<=11784||C===11785||C===11786||C===11787||C===11788||C===11789||C>=11790&&C<=11798||C===11799||C>=11800&&C<=11801||C===11802||C===11803||C===11804||C===11805||C>=11806&&C<=11807||C===11808||C===11809||C===11810||C===11811||C===11812||C===11813||C===11814||C===11815||C===11816||C===11817||C>=11818&&C<=11822||C===11823||C>=11824&&C<=11833||C>=11834&&C<=11835||C>=11836&&C<=11839||C===11840||C===11841||C===11842||C>=11843&&C<=11855||C>=11856&&C<=11857||C===11858||C>=11859&&C<=11903||C>=12289&&C<=12291||C===12296||C===12297||C===12298||C===12299||C===12300||C===12301||C===12302||C===12303||C===12304||C===12305||C>=12306&&C<=12307||C===12308||C===12309||C===12310||C===12311||C===12312||C===12313||C===12314||C===12315||C===12316||C===12317||C>=12318&&C<=12319||C===12320||C===12336||C===64830||C===64831||C>=65093&&C<=65094}function $1(C){C.forEach(function(H){if(delete H.location,Z1(H)||u1(H))for(var V in H.options)delete H.options[V].location,$1(H.options[V].value);else l1(H)&&S1(H.style)||(v1(H)||x1(H))&&L1(H.style)?delete H.style.location:s1(H)&&$1(H.children)})}function G2(C,H){H===void 0&&(H={}),H=p({shouldParseSkeletons:!0,requiresOtherClause:!0},H);var V=new W2(C,H).parse();if(V.err){var L=SyntaxError(m[V.err.kind]);throw L.location=V.err.location,L.originalMessage=V.err.message,L}return H?.captureLocation||$1(V.val),V.val}function r1(C,H){var V=H&&H.cache?H.cache:V3,L=H&&H.serializer?H.serializer:H3,M=H&&H.strategy?H.strategy:Y5;return M(C,{cache:V,serializer:L})}function X5(C){return C==null||typeof C=="number"||typeof C=="boolean"}function Q2(C,H,V,L){var M=X5(L)?L:V(L),r=H.get(M);return typeof r>"u"&&(r=C.call(this,L),H.set(M,r)),r}function $2(C,H,V){var L=Array.prototype.slice.call(arguments,3),M=V(L),r=H.get(M);return typeof r>"u"&&(r=C.apply(this,L),H.set(M,r)),r}function z1(C,H,V,L,M){return V.bind(H,C,L,M)}function Y5(C,H){var V=C.length===1?Q2:$2;return z1(C,this,V,H.cache.create(),H.serializer)}function J5(C,H){return z1(C,this,$2,H.cache.create(),H.serializer)}function C3(C,H){return z1(C,this,Q2,H.cache.create(),H.serializer)}var H3=function(){return JSON.stringify(arguments)};function j1(){this.cache=Object.create(null)}j1.prototype.get=function(C){return this.cache[C]};j1.prototype.set=function(C,H){this.cache[C]=H};var V3={create:function(){return new j1}},c1={variadic:J5,monadic:C3};var D;(function(C){C.MISSING_VALUE="MISSING_VALUE",C.INVALID_VALUE="INVALID_VALUE",C.MISSING_INTL_API="MISSING_INTL_API"})(D||(D={}));var e1=function(C){V1(H,C);function H(V,L,M){var r=C.call(this,V)||this;return r.code=L,r.originalMessage=M,r}return H.prototype.toString=function(){return"[formatjs Error: ".concat(this.code,"] ").concat(this.message)},H}(Error);var q1=function(C){V1(H,C);function H(V,L,M,r){return C.call(this,'Invalid values for "'.concat(V,'": "').concat(L,'". Options are "').concat(Object.keys(M).join('", "'),'"'),D.INVALID_VALUE,r)||this}return H}(e1);var z2=function(C){V1(H,C);function H(V,L,M){return C.call(this,'Value for "'.concat(V,'" must be of type ').concat(L),D.INVALID_VALUE,M)||this}return H}(e1);var j2=function(C){V1(H,C);function H(V,L){return C.call(this,'The intl string context variable "'.concat(V,'" was not provided to the string "').concat(L,'"'),D.MISSING_VALUE,L)||this}return H}(e1);var S;(function(C){C[C.literal=0]="literal",C[C.object=1]="object"})(S||(S={}));function L3(C){return C.length<2?C:C.reduce(function(H,V){var L=H[H.length-1];return!L||L.type!==S.literal||V.type!==S.literal?H.push(V):L.value+=V.value,H},[])}function M3(C){return typeof C=="function"}function t1(C,H,V,L,M,r,e){if(C.length===1&&_1(C[0]))return[{type:S.literal,value:C[0].value}];for(var t=[],o=0,a=C;o0?new Intl.Locale(V[0]):new Intl.Locale(typeof H=="string"?H:H[0])},C.__parse=G2,C.formats={number:{integer:{maximumFractionDigits:0},currency:{style:"currency"},percent:{style:"percent"}},date:{short:{month:"numeric",day:"numeric",year:"2-digit"},medium:{month:"short",day:"numeric",year:"numeric"},long:{month:"long",day:"numeric",year:"numeric"},full:{weekday:"long",month:"long",day:"numeric",year:"numeric"}},time:{short:{hour:"numeric",minute:"numeric"},medium:{hour:"numeric",minute:"numeric",second:"numeric"},long:{hour:"numeric",minute:"numeric",second:"numeric",timeZoneName:"short"},full:{hour:"numeric",minute:"numeric",second:"numeric",timeZoneName:"short"}}},C}();var K2=q2;var X1={en:E1};function v(C,H,...V){let L=H.replace(/['"]+/g,"");var M;try{M=C.split(".").reduce((e,t)=>e[t],X1[L])}catch{M=C.split(".").reduce((t,o)=>t[o],X1.en)}if(M===void 0&&(M=C.split(".").reduce((e,t)=>e[t],X1.en)),!V.length)return M;let r={};for(let e=0;e{await customElements.whenDefined("partial-panel-resolver"),await document.createElement("partial-panel-resolver")._getRoutes([{component_name:"config",url_path:"a"}])?.routes?.a?.load?.(),await customElements.whenDefined("ha-panel-config");let V=document.createElement("ha-panel-config");await V?.routerOptions?.routes?.dashboard?.load?.(),await V?.routerOptions?.routes?.general?.load?.(),await V?.routerOptions?.routes?.entities?.load?.(),await V?.routerOptions?.routes?.labels?.load?.(),await customElements.whenDefined("ha-config-dashboard")};var J2=f1` 6 | :host { 7 | color: var(--primary-text-color); 8 | background: var(--lovelace-background, var(--primary-background-color)); 9 | } 10 | 11 | .header { 12 | background-color: var(--app-header-background-color); 13 | color: var(--app-header-text-color, white); 14 | border-bottom: var(--app-header-border-bottom, none); 15 | } 16 | 17 | .toolbar { 18 | height: var(--header-height); 19 | display: flex; 20 | align-items: center; 21 | font-size: 20px; 22 | padding: 0 16px; 23 | font-weight: 400; 24 | box-sizing: border-box; 25 | } 26 | 27 | .main-title { 28 | margin: 0 0 0 24px; 29 | line-height: 20px; 30 | flex-grow: 1; 31 | } 32 | 33 | .version { 34 | font-size: 14px; 35 | font-weight: 500; 36 | color: rgba(var(--rgb-text-primary-color), 0.9); 37 | } 38 | 39 | .view { 40 | height: calc(100vh - 65px); 41 | display: flex; 42 | align-content: start; 43 | justify-content: center; 44 | flex-wrap: wrap; 45 | align-items: flex-start; 46 | } 47 | 48 | ha-card { 49 | display: block; 50 | margin: 5px; 51 | } 52 | 53 | .card-new { 54 | width: 500px; 55 | max-width: 500px; 56 | } 57 | 58 | .card-current { 59 | width: 850px; 60 | max-width: 850px; 61 | } 62 | 63 | ha-expansion-panel { 64 | --input-fill-color: none; 65 | } 66 | 67 | .form-row { 68 | display: flex; 69 | justify-content: center; 70 | gap: 8px; 71 | flex-wrap: wrap; 72 | } 73 | 74 | .form-field, 75 | ha-textfield, 76 | ha-select, 77 | ha-icon-picker { 78 | min-width: 265px; 79 | } 80 | 81 | .filler { 82 | flex-grow: 1; 83 | } 84 | 85 | .break { 86 | flex-basis: 100%; 87 | height: 0; 88 | } 89 | 90 | @media (max-width: 600px) { 91 | .form-row { 92 | flex-direction: column; /* Stack fields vertically */ 93 | } 94 | 95 | .form-field { 96 | width: 100%; /* Full width */ 97 | } 98 | 99 | ha-textfield, 100 | ha-select, 101 | ha-icon-picker { 102 | width: 100%; 103 | box-sizing: border-box; 104 | } 105 | } 106 | 107 | .task-list { 108 | list-style: none; 109 | padding: 0; 110 | margin: 0; 111 | } 112 | 113 | .task-item { 114 | display: flex; 115 | flex-wrap: wrap; 116 | justify-content: space-between; 117 | align-items: center; 118 | margin-bottom: 12px; 119 | gap: 1rem; 120 | padding: 0.5rem 0; 121 | border-bottom: 1px solid var(--divider-color); 122 | } 123 | 124 | .task-header { 125 | display: flex; 126 | align-items: center; 127 | gap: 8px; 128 | } 129 | 130 | .task-content { 131 | flex: 1; 132 | } 133 | 134 | .task-actions { 135 | display: flex; 136 | flex-direction: row; 137 | gap: 0.5rem; 138 | } 139 | 140 | .due-soon { 141 | color: var(--error-color, red); 142 | font-weight: bold; 143 | } 144 | 145 | .warning { 146 | --mdc-theme-primary: var(--error-color); 147 | color: var(--primary-text-color); 148 | } 149 | 150 | ha-dialog { 151 | --mdc-dialog-min-width: 600px; 152 | } 153 | 154 | @media (max-width: 600px) { 155 | ha-dialog { 156 | --mdc-dialog-min-width: auto; 157 | } 158 | } 159 | `;var Y1=["days","weeks","months"];function J1(C){return{days:v("intervals.days",C),weeks:v("intervals.weeks",C),months:v("intervals.months",C)}}var C5=C=>C.connection.sendMessagePromise({type:"tag/list"}),H5=C=>C.callWS({type:"config/entity_registry/list"}),V5=C=>C.callWS({type:"config/label_registry/list"}),C2=C=>C.callWS({type:"home_maintenance/get_tasks"}),L5=(C,H)=>C.callWS({type:"home_maintenance/get_task",task_id:H}),M5=(C,H)=>C.callWS({type:"home_maintenance/add_task",...H}),r5=(C,H)=>C.callWS({type:"home_maintenance/remove_task",task_id:H}),e5=(C,H)=>C.callWS({type:"home_maintenance/complete_task",task_id:H}),t5=(C,H)=>C.callWS({type:"home_maintenance/update_task",...H}),i5=C=>C.callWS({type:"home_maintenance/get_config"});var c=class extends B{constructor(){super(...arguments);this.tags=null;this.tasks=[];this.config=null;this.registry=[];this.labelRegistry=[];this._formData={title:"",interval_value:"",interval_type:"days",last_performed:"",icon:"",label:[],tag:""};this._advancedOpen=!1;this._editingTaskId=null;this._editFormData={title:"",interval_value:"",interval_type:"days",last_performed:"",icon:"",label:[],tag:""};this._computeLabel=V=>{try{return v(`panel.cards.new.fields.${V.name}.heading`,this.hass.language)??V.name}catch{return V.name}};this._computeHelper=V=>{try{return v(`panel.cards.new.fields.${V.name}.helper`,this.hass.language)??""}catch{return""}};this._computeEditLabel=V=>{try{return v(`panel.dialog.edit_task.fields.${V.name}.heading`,this.hass.language)??V.name}catch{return V.name}};this._computeEditHelper=V=>{try{return v(`panel.dialog.edit_task.fields.${V.name}.helper`,this.hass.language)??""}catch{return""}}}get _columns(){return{icon:{title:"",moveable:!1,showNarrow:!1,label:"icon",type:"icon",template:V=>V.icon?u``:Z},tagIcon:{title:"",moveable:!1,showNarrow:!1,label:"tag",type:"icon",template:V=>V.tagIcon?u``:Z},title:{title:"Title",main:!0,showNarrow:!0,sortable:!0,filterable:!0,grows:!0,extraTemplate:V=>{let L=this.registry.find(r=>r.unique_id===V.id);if(!L)return Z;let M=this.labelRegistry.filter(r=>L.labels.includes(r.label_id));return M.length?u``:Z}},interval_days:{title:"Interval",showNarrow:!1,sortable:!0,minWidth:"100px",maxWidth:"100px",template:V=>{let L=V.interval_type,r=V.interval_value===1?L.slice(0,-1):L;return`${V.interval_value} ${v(`intervals.${r}`,this.hass.language)}`}},last_performed:{title:"Last Performed",showNarrow:!1,sortable:!0,minWidth:"150px",maxWidth:"150px",template:V=>{if(!V.last_performed)return"-";let L=new Date(this.computeISODate(V.last_performed));return F1(L,this.hass.locale)}},next_due:{title:v("panel.cards.current.next",this.hass.language),showNarrow:!0,sortable:!0,direction:"asc",minWidth:"100px",maxWidth:"100px",template:V=>{let L=new Date,M=new Date(V.next_due),r=M<=L;return u` 160 | 161 | ${F1(M,this.hass.locale)} 162 | `||"\u2014"}},complete:{minWidth:"64px",maxWidth:"64px",sortable:!1,groupable:!1,showNarrow:!0,moveable:!1,hideable:!1,type:"overflow",template:V=>u` 163 | this._handleCompleteTaskClick(V.id)} 165 | .label="Complete" 166 | title="Mark Task Complete" 167 | .path=${L2} 168 | > 169 | `},actions:{title:"",label:"actions",showNarrow:!0,moveable:!1,hideable:!1,type:"overflow-menu",template:V=>u` 170 | this._handleOpenEditDialogClick(V.id)},{label:v("panel.cards.current.actions.remove",this.hass.language),path:M2,action:()=>this._handleRemoveTaskClick(V.id),warning:!0}]} 174 | > 175 | 176 | `}}}get _columnsToDisplay(){return Object.fromEntries(Object.entries(this._columns).filter(([V,L])=>this.narrow?L.showNarrow!==!1:!0))}get _rows(){return this.tasks.map(V=>({icon:V.icon,id:V.id,title:V.title,interval_value:V.interval_value,interval_type:V.interval_type,last_performed:V.last_performed??"Never",interval_days:(()=>{switch(V.interval_type){case"days":return V.interval_value;case"weeks":return V.interval_value*7;case"months":return V.interval_value*30;default:return Number.MAX_SAFE_INTEGER}})(),next_due:(()=>{let[L]=V.last_performed.split("T"),[M,r,e]=L.split("-").map(Number),t=new Date(M,r-1,e);switch(V.interval_type){case"days":t.setDate(t.getDate()+V.interval_value);break;case"weeks":t.setDate(t.getDate()+V.interval_value*7);break;case"months":t.setMonth(t.getMonth()+V.interval_value);break;default:throw new Error(`Unsupported interval type: ${V.interval_type}`)}return t})(),tagIcon:V.tag_id&&V.tag_id.trim()!==""?"mdi:tag":void 0}))}get _basicSchema(){return[{name:"title",required:!0,selector:{text:{}}},{name:"interval_value",required:!0,selector:{number:{min:1,mode:"box"}}},{name:"interval_type",required:!0,selector:{select:{options:Y1.map(V=>({value:V,label:J1(this.hass.language)[V]})),mode:"dropdown"}}}]}get _advancedSchema(){return[{name:"last_performed",selector:{date:{}}},{name:"icon",selector:{icon:{}}},{name:"label",selector:{label:{multiple:!0}}},{name:"tag",selector:{entity:{filter:{domain:"tag"}}}}]}get _editSchema(){return[{name:"interval_value",required:!0,selector:{number:{min:1,mode:"box"}}},{name:"interval_type",required:!0,selector:{select:{options:Y1.map(V=>({value:V,label:J1(this.hass.language)[V]})),mode:"dropdown"}}},{type:"constant",name:v("panel.dialog.edit_task.sections.optional",this.hass.language),disabled:!0},{name:"last_performed",selector:{date:{}}},{name:"icon",selector:{icon:{}}},{name:"label",selector:{label:{multiple:!0}}},{name:"tag",selector:{entity:{filter:{domain:"tag"}}}}]}async loadData(){await Y2(),this.tags=await C5(this.hass),this.tasks=await C2(this.hass),this.config=await i5(this.hass),this.registry=await H5(this.hass),this.labelRegistry=await V5(this.hass)}async resetForm(){this._formData={title:"",interval_value:"",interval_type:"days",last_performed:"",icon:"",label:[],tag:""},this.tasks=await C2(this.hass)}async resetEditForm(){this._editFormData={title:"",interval_value:"",interval_type:"days",last_performed:"",icon:"",label:[],tag:""}}computeISODate(V){let L;if(V){let[M,r,e]=V.split("T")[0].split("-"),t=Number(M),o=Number(r),a=Number(e);if(!isNaN(t)&&!isNaN(o)&&!isNaN(a)){let i=new Date(t,o-1,a);i.setHours(0,0,0,0),L=i.toISOString()}else{alert("Invalid date entered.");let i=new Date;i.setHours(0,0,0,0),L=i.toISOString()}}else{let M=new Date;M.setHours(0,0,0,0),L=M.toISOString()}return L}connectedCallback(){super.connectedCallback(),this.loadData()}render(){return this.hass?!this.tasks||!this.tags?u`

${v("common.loading",this.hass.language)}

`:u` 177 |
178 |
179 | 180 |
181 | ${this.config?.options.sidebar_title} 182 |
183 |
184 | v${X2} 185 |
186 |
187 |
188 | 189 |
190 | 194 |
${this.renderForm()}
195 |
196 | 197 | 201 |
${this.renderTasks()}
202 |
203 |
204 | 205 | ${this.renderEditDialog()} 206 | `:u``}renderForm(){return this.hass?u` 207 | this._handleFormValueChanged(V)} 214 | > 215 | 216 | this._advancedOpen=V.detail.value} 220 | > 221 | this._handleFormValueChanged(V)} 228 | > 229 | 230 | 231 |
232 | 233 | ${v("panel.cards.new.actions.add_task",this.hass.language)} 234 | 235 |
236 | `:u``}renderTasks(){return this.hass?!this.tasks||this.tasks.length===0?u`${v("common.no_tasks",this.hass.language)}`:u` 237 |
238 | 248 | 249 |
250 | `:u``}renderEditDialog(){return this.hass?this._editingTaskId?u` 251 | 256 | this._handleEditFormValueChanged(V)} 263 | > 264 | 265 | this._editingTaskId=null}> 266 | ${v("panel.dialog.edit_task.actions.cancel",this.hass.language)} 267 | 268 | 269 | ${v("panel.dialog.edit_task.actions.save",this.hass.language)} 270 | 271 | 272 | `:u``:u``}async _handleAddTaskClick(){let{title:V,interval_value:L,interval_type:M,last_performed:r,tag:e,icon:t,label:o}=this._formData;if(!V?.trim()||!L||!M){let i=v("panel.cards.new.alerts.required",this.hass.language);alert(i);return}let a={title:V.trim(),interval_value:L,interval_type:M,last_performed:this.computeISODate(r),tag_id:e?.trim()||void 0,icon:t?.trim()||"mdi:calendar-check",labels:o??[]};try{await M5(this.hass,a),await this.resetForm()}catch(i){console.error("Failed to add task:",i);let A=v("panel.cards.new.alerts.error",this.hass.language);alert(A)}}async _handleCompleteTaskClick(V){try{await e5(this.hass,V),await this.loadData()}catch(L){console.error("Failed to complete task:",L)}}async _handleOpenEditDialogClick(V){try{let L=await L5(this.hass,V);this._editingTaskId=L.id;let M=[],r=this.registry.find(e=>e.unique_id===L.id);r&&(M=this.labelRegistry.filter(e=>r.labels.includes(e.label_id))),this._editFormData={title:L.title,interval_value:L.interval_value,interval_type:L.interval_type,last_performed:L.last_performed??"",icon:L.icon??"",label:M.map(e=>e.label_id),tag:L.tag_id??""},await this.updateComplete}catch(L){console.error("Failed to fetch task for edit:",L)}}async _handleSaveEditClick(){if(!this._editingTaskId)return;let V=this.computeISODate(this._editFormData.last_performed);if(!V)return;let L={title:this._editFormData.title.trim(),interval_value:Number(this._editFormData.interval_value),interval_type:this._editFormData.interval_type,last_performed:V,icon:this._editFormData.icon?.trim()||"mdi:calendar-check",labels:this._editFormData.label};this._editFormData.tag&&this._editFormData.tag.trim()!==""?L.tag_id=this._editFormData.tag.trim():L.tag_id=null;let M={task_id:this._editingTaskId,updates:L};try{await t5(this.hass,M),this._editingTaskId=null,await this.resetEditForm(),await this.loadData()}catch(r){console.error("Failed to update task:",r)}}async _handleRemoveTaskClick(V){let L=v("panel.cards.current.confirm_remove",this.hass.language);if(confirm(L))try{await r5(this.hass,V),await this.loadData()}catch(M){console.error("Failed to remove task:",M)}}_handleDialogClosed(V){let L=V.detail?.action;(L==="close"||L==="cancel")&&(this._editingTaskId=null)}_handleFormValueChanged(V){this._formData={...this._formData,...V.detail.value}}_handleEditFormValueChanged(V){this._editFormData={...this._editFormData,...V.detail.value}}};c.styles=J2,h([H1()],c.prototype,"hass",2),h([H1()],c.prototype,"narrow",2),h([b()],c.prototype,"tags",2),h([b()],c.prototype,"tasks",2),h([b()],c.prototype,"config",2),h([b()],c.prototype,"registry",2),h([b()],c.prototype,"labelRegistry",2),h([b()],c.prototype,"_formData",2),h([b()],c.prototype,"_editingTaskId",2),h([b()],c.prototype,"_editFormData",2);customElements.define("home-maintenance-panel",c);export{c as HomeMaintenancePanel}; 273 | /*! Bundled license information: 274 | 275 | @lit/reactive-element/css-tag.js: 276 | (** 277 | * @license 278 | * Copyright 2019 Google LLC 279 | * SPDX-License-Identifier: BSD-3-Clause 280 | *) 281 | 282 | @lit/reactive-element/reactive-element.js: 283 | lit-html/lit-html.js: 284 | lit-element/lit-element.js: 285 | @lit/reactive-element/decorators/custom-element.js: 286 | @lit/reactive-element/decorators/property.js: 287 | @lit/reactive-element/decorators/state.js: 288 | @lit/reactive-element/decorators/event-options.js: 289 | @lit/reactive-element/decorators/base.js: 290 | @lit/reactive-element/decorators/query.js: 291 | @lit/reactive-element/decorators/query-all.js: 292 | @lit/reactive-element/decorators/query-async.js: 293 | @lit/reactive-element/decorators/query-assigned-nodes.js: 294 | (** 295 | * @license 296 | * Copyright 2017 Google LLC 297 | * SPDX-License-Identifier: BSD-3-Clause 298 | *) 299 | 300 | lit-html/is-server.js: 301 | (** 302 | * @license 303 | * Copyright 2022 Google LLC 304 | * SPDX-License-Identifier: BSD-3-Clause 305 | *) 306 | 307 | @lit/reactive-element/decorators/query-assigned-elements.js: 308 | (** 309 | * @license 310 | * Copyright 2021 Google LLC 311 | * SPDX-License-Identifier: BSD-3-Clause 312 | *) 313 | */ 314 | --------------------------------------------------------------------------------