From 4678207081f884401df9d67a11ed61451d9a01df Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 17 Dec 2025 19:15:28 +0330 Subject: [PATCH] new ui and daily report --- package-lock.json | 1129 ++++++++++++++++- package.json | 6 +- src/app/alert-settings/page.tsx | 726 +++++++++++ src/app/calendar/page.tsx | 9 +- src/app/daily-report/page.tsx | 593 +++++++++ src/app/day-details/page.tsx | 24 +- src/app/device-settings/page.tsx | 23 +- src/app/devices/page.tsx | 61 +- src/app/globals.css | 72 ++ .../telemetry/GreenHomeBack.code-workspace | 10 + src/app/telemetry/page.tsx | 315 ----- src/components/Charts.tsx | 157 ++- src/components/Gauges.tsx | 330 +++++ src/components/MiniChart.tsx | 70 + src/components/daily-report/AnalysisTab.tsx | 80 ++ src/components/daily-report/ChartsTab.tsx | 104 ++ src/components/daily-report/SummaryCard.tsx | 82 ++ src/components/daily-report/SummaryTab.tsx | 77 ++ .../daily-report/TimeRangeSelector.tsx | 265 ++++ src/components/daily-report/WeatherTab.tsx | 516 ++++++++ src/components/daily-report/index.ts | 10 + src/components/daily-report/types.ts | 43 + src/components/daily-report/utils.ts | 171 +++ .../daily-report/weather-helpers.ts | 111 ++ src/lib/api.ts | 69 +- src/lib/persian-date.ts | 54 + 26 files changed, 4715 insertions(+), 392 deletions(-) create mode 100644 src/app/alert-settings/page.tsx create mode 100644 src/app/daily-report/page.tsx create mode 100644 src/app/telemetry/GreenHomeBack.code-workspace delete mode 100644 src/app/telemetry/page.tsx create mode 100644 src/components/Gauges.tsx create mode 100644 src/components/MiniChart.tsx create mode 100644 src/components/daily-report/AnalysisTab.tsx create mode 100644 src/components/daily-report/ChartsTab.tsx create mode 100644 src/components/daily-report/SummaryCard.tsx create mode 100644 src/components/daily-report/SummaryTab.tsx create mode 100644 src/components/daily-report/TimeRangeSelector.tsx create mode 100644 src/components/daily-report/WeatherTab.tsx create mode 100644 src/components/daily-report/index.ts create mode 100644 src/components/daily-report/types.ts create mode 100644 src/components/daily-report/utils.ts create mode 100644 src/components/daily-report/weather-helpers.ts diff --git a/package-lock.json b/package-lock.json index 2dcbe52..f309c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,17 @@ "version": "0.1.0", "dependencies": { "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", + "chartjs-plugin-annotation": "^3.1.0", + "date-fns": "^4.1.0", "jalaali-js": "^1.2.8", "lucide-react": "^0.553.0", "next": "15.5.4", "next-pwa": "^5.6.0", "react": "19.1.0", "react-chartjs-2": "^5.3.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-markdown": "^10.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2897,6 +2901,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2925,6 +2937,14 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -2935,6 +2955,14 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2948,12 +2976,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "20.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", @@ -2967,7 +3008,6 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2998,6 +3038,11 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", @@ -3286,6 +3331,11 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -4171,6 +4221,15 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4345,6 +4404,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4362,6 +4430,42 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", @@ -4374,6 +4478,23 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, + "node_modules/chartjs-plugin-annotation": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz", + "integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==", + "peerDependencies": { + "chart.js": ">=4.0.0" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4435,6 +4556,15 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4509,7 +4639,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4570,6 +4699,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4587,6 +4725,18 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4692,6 +4842,14 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4702,6 +4860,18 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5405,6 +5575,15 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", @@ -5430,6 +5609,11 @@ "node": ">=0.8.x" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5993,6 +6177,53 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -6052,6 +6283,11 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6066,6 +6302,28 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6203,6 +6461,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6258,6 +6525,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -6355,6 +6631,17 @@ "node": ">=6" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7088,6 +7375,15 @@ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7167,6 +7463,151 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7182,6 +7623,427 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7645,6 +8507,29 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7878,6 +8763,15 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7954,6 +8848,32 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8049,6 +8969,37 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8550,6 +9501,15 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -8679,6 +9639,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -8725,6 +9698,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -9010,6 +9999,24 @@ "punycode": "^2.1.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -9219,6 +10226,24 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -9231,6 +10256,69 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -9324,6 +10412,32 @@ "punycode": "^2.1.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -9908,6 +11022,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 8c905a0..d62b7b9 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,17 @@ }, "dependencies": { "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", + "chartjs-plugin-annotation": "^3.1.0", + "date-fns": "^4.1.0", "jalaali-js": "^1.2.8", "lucide-react": "^0.553.0", "next": "15.5.4", "next-pwa": "^5.6.0", "react": "19.1.0", "react-chartjs-2": "^5.3.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-markdown": "^10.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/alert-settings/page.tsx b/src/app/alert-settings/page.tsx new file mode 100644 index 0000000..003bc5c --- /dev/null +++ b/src/app/alert-settings/page.tsx @@ -0,0 +1,726 @@ +"use client" +import { useEffect, useState, useCallback } from 'react' +import { useSearchParams } from 'next/navigation' +import { + api, + AlertConditionDto, + CreateAlertConditionDto, + UpdateAlertConditionDto, + AlertRuleDto +} from '@/lib/api' +import { + Bell, + Plus, + Pencil, + Trash2, + X, + AlertTriangle, + Thermometer, + Droplets, + Leaf, + Wind, + Sun, + Check, + Phone, + MessageSquare, + Clock, + Moon, + Zap, + Maximize2, + ArrowLeftRight +} from 'lucide-react' +import Loading from '@/components/Loading' + +type SensorType = 0 | 1 | 2 | 3 | 4 +type ComparisonType = 0 | 1 | 2 | 3 +type NotificationType = 0 | 1 +type TimeType = 0 | 1 | 2 + +const SENSOR_TYPES: { value: SensorType; label: string; icon: any; unit: string }[] = [ + { value: 0, label: 'دما', icon: Thermometer, unit: '°C' }, + { value: 1, label: 'رطوبت هوا', icon: Droplets, unit: '%' }, + { value: 2, label: 'رطوبت خاک', icon: Leaf, unit: '%' }, + { value: 3, label: 'گاز', icon: Wind, unit: 'PPM' }, + { value: 4, label: 'نور', icon: Sun, unit: 'Lux' } +] + +const COMPARISON_TYPES: { value: ComparisonType; label: string; icon: any; needsMax: boolean }[] = [ + { value: 0, label: 'بزرگتر از', icon: () => >, needsMax: false }, + { value: 1, label: 'کوچکتر از', icon: () => <, needsMax: false }, + { value: 2, label: 'بین', icon: ArrowLeftRight, needsMax: true }, + { value: 3, label: 'خارج از محدوده', icon: Maximize2, needsMax: true } +] + +const NOTIFICATION_TYPES: { value: NotificationType; label: string; icon: any }[] = [ + { value: 0, label: 'تماس تلفنی', icon: Phone }, + { value: 1, label: 'پیامک', icon: MessageSquare } +] + +const TIME_TYPES: { value: TimeType; label: string; icon: any; description: string }[] = [ + { value: 0, label: 'روز', icon: Sun, description: 'فقط در روز بررسی شود' }, + { value: 1, label: 'شب', icon: Moon, description: 'فقط در شب بررسی شود' }, + { value: 2, label: 'همیشه', icon: Zap, description: 'در هر زمان بررسی شود' } +] + +export default function AlertSettingsPage() { + const searchParams = useSearchParams() + const deviceId = Number(searchParams.get('deviceId') ?? '1') + + const [alerts, setAlerts] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [editingAlert, setEditingAlert] = useState(null) + const [saving, setSaving] = useState(false) + + // Form state + const [formData, setFormData] = useState({ + deviceId: deviceId, + notificationType: 0, + timeType: 2, + isActive: true, + rules: [] + }) + + const loadAlerts = useCallback(async () => { + setLoading(true) + try { + const data = await api.getAlertConditions(deviceId) + setAlerts(data) + } catch (error) { + console.error('Error loading alerts:', error) + } finally { + setLoading(false) + } + }, [deviceId]) + + useEffect(() => { + loadAlerts() + }, [loadAlerts]) + + const openCreateModal = () => { + setEditingAlert(null) + setFormData({ + deviceId: deviceId, + notificationType: 0, + timeType: 2, + isActive: true, + rules: [{ + sensorType: 0, + comparisonType: 0, + threshold: 30 + }] + }) + setShowModal(true) + } + + const openEditModal = (alert: AlertConditionDto) => { + setEditingAlert(alert) + setFormData({ + deviceId: alert.deviceId, + notificationType: alert.notificationType, + timeType: alert.timeType, + isActive: alert.isActive, + rules: alert.rules.map(r => ({ ...r })) + }) + setShowModal(true) + } + + const closeModal = () => { + setShowModal(false) + setEditingAlert(null) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (formData.rules.length === 0) { + alert('لطفاً حداقل یک قانون اضافه کنید') + return + } + + setSaving(true) + + try { + if (editingAlert) { + // Update + const updateDto: UpdateAlertConditionDto = { + id: editingAlert.id, + ...formData + } + await api.updateAlertCondition(updateDto) + } else { + // Create + await api.createAlertCondition(formData) + } + + await loadAlerts() + closeModal() + } catch (error) { + console.error('Error saving alert:', error) + alert('خطا در ذخیره هشدار. لطفاً دوباره تلاش کنید.') + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: number) => { + if (!confirm('آیا از حذف این هشدار اطمینان دارید؟')) { + return + } + + try { + await api.deleteAlertCondition(id) + await loadAlerts() + } catch (error) { + console.error('Error deleting alert:', error) + alert('خطا در حذف هشدار. لطفاً دوباره تلاش کنید.') + } + } + + const toggleActive = async (alert: AlertConditionDto) => { + try { + const updateDto: UpdateAlertConditionDto = { + id: alert.id, + deviceId: alert.deviceId, + notificationType: alert.notificationType, + timeType: alert.timeType, + isActive: !alert.isActive, + rules: alert.rules + } + await api.updateAlertCondition(updateDto) + await loadAlerts() + } catch (error) { + console.error('Error toggling alert:', error) + alert('خطا در تغییر وضعیت هشدار.') + } + } + + const addRule = () => { + setFormData({ + ...formData, + rules: [ + ...formData.rules, + { + sensorType: 0, + comparisonType: 0, + threshold: 30 + } + ] + }) + } + + const removeRule = (index: number) => { + setFormData({ + ...formData, + rules: formData.rules.filter((_, i) => i !== index) + }) + } + + const updateRule = (index: number, updates: Partial) => { + const newRules = [...formData.rules] + newRules[index] = { ...newRules[index], ...updates } + setFormData({ ...formData, rules: newRules }) + } + + const getSensorIcon = (type: SensorType) => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor ? sensor.icon : AlertTriangle + } + + const getSensorLabel = (type: SensorType) => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor?.label ?? type.toString() + } + + const getSensorUnit = (type: SensorType) => { + const sensor = SENSOR_TYPES.find(s => s.value === type) + return sensor?.unit ?? '' + } + + const getComparisonLabel = (type: ComparisonType) => { + const comp = COMPARISON_TYPES.find(c => c.value === type) + return comp?.label ?? type.toString() + } + + const getNotificationLabel = (type: NotificationType) => { + const notif = NOTIFICATION_TYPES.find(n => n.value === type) + return notif?.label ?? type.toString() + } + + const getTimeTypeLabel = (type: TimeType) => { + const time = TIME_TYPES.find(t => t.value === type) + return time?.label ?? type.toString() + } + + const formatRuleText = (rule: AlertRuleDto) => { + const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType) + if (comp?.needsMax) { + return `${getComparisonLabel(rule.comparisonType)}: ${rule.threshold} - ${rule.thresholdMax ?? '?'} ${getSensorUnit(rule.sensorType)}` + } + const symbol = rule.comparisonType === 0 ? '>' : rule.comparisonType === 1 ? '<' : '?' + return `${symbol} ${rule.threshold} ${getSensorUnit(rule.sensorType)}` + } + + const generatePreviewText = () => { + if (formData.rules.length === 0) { + return 'هنوز هیچ شرطی تعریف نشده است.' + } + + // نوع اطلاع‌رسانی + const notifText = formData.notificationType === 0 ? 'تماس تلفنی' : 'پیامک' + + // زمان + const timeText = formData.timeType === 0 + ? 'در روز' + : formData.timeType === 1 + ? 'در شب' + : 'در هر زمان' + + // قوانین + const rulesText = formData.rules.map(rule => { + const sensorName = getSensorLabel(rule.sensorType) + const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType) + + if (comp?.needsMax) { + // بین یا خارج از محدوده + const compText = rule.comparisonType === 2 ? 'بین' : 'خارج از' + return `${sensorName} ${compText} ${rule.threshold} تا ${rule.thresholdMax ?? '؟'} ${getSensorUnit(rule.sensorType)}` + } else { + // بزرگتر یا کوچکتر + const compText = rule.comparisonType === 0 ? 'بیشتر از' : 'کمتر از' + return `${sensorName} ${compText} ${rule.threshold} ${getSensorUnit(rule.sensorType)}` + } + }) + + // ساخت جمله نهایی + if (rulesText.length === 1) { + return `ارسال ${notifText} برای زمانی که ${timeText} ${rulesText[0]} باشد.` + } else { + const lastRule = rulesText[rulesText.length - 1] + const otherRules = rulesText.slice(0, -1).join(' و ') + return `ارسال ${notifText} برای زمانی که ${timeText} ${otherRules} و ${lastRule} باشند.` + } + } + + if (loading) { + return + } + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

+ تنظیمات هشدارها +

+

+ مدیریت شرایط و هشدارهای دستگاه +

+
+
+ +
+
+ + {/* Alerts List */} +
+
+

+ لیست هشدارها ({alerts.length}) +

+
+ + {alerts.length === 0 ? ( +
+ +

هیچ هشداری ثبت نشده است

+ +
+ ) : ( +
+ {alerts.map((alert) => ( +
+
+
+ {/* Header */} +
+
+ {NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && ( + + {(() => { + const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon + return + })()} + + )} + {getNotificationLabel(alert.notificationType)} +
+
+ {TIME_TYPES.find(t => t.value === alert.timeType)?.icon && ( + + {(() => { + const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon + return + })()} + + )} + {getTimeTypeLabel(alert.timeType)} +
+ +
+ + {/* Rules */} +
+
شرایط:
+
+ {alert.rules.map((rule, idx) => { + const Icon = getSensorIcon(rule.sensorType) + return ( +
+ + {getSensorLabel(rule.sensorType)} + {formatRuleText(rule)} +
+ ) + })} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ))} +
+ )} +
+
+ + {/* Modal */} + {showModal && ( +
+
+ {/* Modal Header */} +
+

+ {editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'} +

+ +
+ + {/* Modal Body */} +
+ {/* Notification Type */} +
+ +
+ {NOTIFICATION_TYPES.map(notif => { + const Icon = notif.icon + const isSelected = formData.notificationType === notif.value + return ( + + ) + })} +
+
+ + {/* Time Type */} +
+ +

+ مشخص کنید شرایط در چه زمانی بررسی و هشدار ارسال شود +

+
+ {TIME_TYPES.map(time => { + const Icon = time.icon + const isSelected = formData.timeType === time.value + return ( + + ) + })} +
+
+ + {/* Rules */} +
+
+
+ +

+ {formData.rules.length === 0 + ? 'هیچ شرطی تعریف نشده است' + : formData.rules.length === 1 + ? 'یک شرط تعریف شده است' + : `${formData.rules.length} شرط تعریف شده است (همه باید برقرار باشند)` + } +

+
+ +
+ +
+ {formData.rules.map((rule, index) => { + const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax + return ( +
+
+ + {formData.rules.length === 1 ? 'شرط' : `شرط ${index + 1}`} + + {formData.rules.length > 1 && ( + + )} +
+ + {/* Sensor Type */} +
+ +
+ {SENSOR_TYPES.map(sensor => { + const Icon = sensor.icon + const isSelected = rule.sensorType === sensor.value + return ( + + ) + })} +
+
+ + {/* Comparison Type */} +
+ +
+ {COMPARISON_TYPES.map(comp => { + const Icon = comp.icon + const isSelected = rule.comparisonType === comp.value + return ( + + ) + })} +
+
+ + {/* Threshold */} +
+
+ + updateRule(index, { threshold: Number(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm" + required + /> +
+ {needsMax && ( +
+ + updateRule(index, { thresholdMax: Number(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm" + required + /> +
+ )} +
+
+ ) + })} +
+
+ + {/* Preview */} +
+
+
+ +
+
+
پیش‌نمایش هشدار
+
+ {generatePreviewText()} +
+
+
+
+ + {/* Active Status */} +
+ setFormData({ ...formData, isActive: e.target.checked })} + className="w-4 h-4 text-orange-600 border-gray-300 rounded focus:ring-orange-500" + /> + +
+ + {/* Modal Footer */} +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 304b86c..a1fe848 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -1,5 +1,6 @@ "use client" import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' import { api } from '@/lib/api' import { getCurrentPersianYear } from '@/lib/persian-date' import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react' @@ -22,6 +23,7 @@ function useQueryParam(name: string) { } export default function CalendarPage() { + const router = useRouter() const deviceIdParam = useQueryParam('deviceId') const [deviceId, setDeviceId] = useState(1) const [year, setYear] = useState(getCurrentPersianYear()) @@ -131,9 +133,10 @@ export default function CalendarPage() { const isActive = activeMonths.includes(m) const stats = monthDays[m] return ( - isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)} + disabled={!isActive} className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${ isActive ? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1' @@ -159,7 +162,7 @@ export default function CalendarPage() { {isActive && (
)} - + ) })}
diff --git a/src/app/daily-report/page.tsx b/src/app/daily-report/page.tsx new file mode 100644 index 0000000..0583534 --- /dev/null +++ b/src/app/daily-report/page.tsx @@ -0,0 +1,593 @@ +"use client" +import { useEffect, useMemo, useState, useCallback } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { api, TelemetryDto, DailyReportDto } from '@/lib/api' +import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date' +import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react' +import Link from 'next/link' +import Loading from '@/components/Loading' +import { + SummaryTab, + ChartsTab, + WeatherTab, + AnalysisTab, + TABS, + TabType, + WeatherData, + ensureDateFormat, + formatPersianDate, + QOM_LAT, + QOM_LON, + detectDataGaps, + DataGap +} from '@/components/daily-report' + +export default function DailyReportPage() { + const router = useRouter() + const searchParams = useSearchParams() + const [telemetry, setTelemetry] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('summary') + const [dailyReport, setDailyReport] = useState(null) + const [analysisLoading, setAnalysisLoading] = useState(false) + const [analysisError, setAnalysisError] = useState(null) + const [weatherData, setWeatherData] = useState(null) + const [weatherLoading, setWeatherLoading] = useState(false) + const [weatherError, setWeatherError] = useState(null) + const [expandedDayIndex, setExpandedDayIndex] = useState(null) + const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00 + const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59 + + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay()) + + const selectedDate = useMemo(() => { + if (!dateParam) return null + try { + const decodedDate = decodeURIComponent(dateParam) + // Ensure date is in yyyy/MM/dd format + return ensureDateFormat(decodedDate) + } catch (error) { + console.error('Error decoding date parameter:', error) + return null + } + }, [dateParam]) + + // Navigate to previous day + const goToPreviousDay = useCallback(() => { + if (!selectedDate) return + const prevDay = getPreviousPersianDay(selectedDate) + if (prevDay) { + router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(prevDay)}`) + } + }, [selectedDate, deviceId, router]) + + // Navigate to next day + const goToNextDay = useCallback(() => { + if (!selectedDate) return + const nextDay = getNextPersianDay(selectedDate) + if (nextDay) { + router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(nextDay)}`) + } + }, [selectedDate, deviceId, router]) + + // Navigate to calendar to select a date + const goToCalendar = useCallback(() => { + router.push(`/calendar?deviceId=${deviceId}`) + }, [deviceId, router]) + + const loadData = useCallback(async () => { + if (!selectedDate) { + setLoading(false) + return + } + + setLoading(true) + + try { + const [year, month, day] = selectedDate.split('/').map(Number) + const startDate = persianToGregorian(year, month, day) + startDate.setHours(0, 0, 0, 0) + const endDate = new Date(startDate) + endDate.setHours(23, 59, 59, 999) + + const startUtc = startDate.toISOString() + const endUtc = endDate.toISOString() + + const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 }) + setTelemetry(result.items) + } catch (error) { + console.error('Error loading telemetry:', error) + } finally { + setLoading(false) + } + }, [deviceId, selectedDate]) + + const loadAnalysis = useCallback(async () => { + if (!selectedDate || dailyReport) return + + setAnalysisLoading(true) + setAnalysisError(null) + + try { + const report = await api.getDailyReport(deviceId, selectedDate) + setDailyReport(report) + } catch (error) { + console.error('Error loading analysis:', error) + setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.') + } finally { + setAnalysisLoading(false) + } + }, [deviceId, selectedDate, dailyReport]) + + const loadWeather = useCallback(async () => { + if (weatherData) return + + setWeatherLoading(true) + setWeatherError(null) + + try { + if (!selectedDate) { + setWeatherError('تاریخ انتخاب نشده است') + return + } + + // تبدیل تاریخ شمسی به میلادی + const [year, month, day] = selectedDate.split('/').map(Number) + const gregorianDate = persianToGregorian(year, month, day) + + // بررسی اینکه تاریخ امروز است یا گذشته + const today = new Date() + today.setHours(0, 0, 0, 0) + gregorianDate.setHours(0, 0, 0, 0) + + const isPast = gregorianDate.getTime() < today.getTime() + + let weather: WeatherData + + if (isPast) { + // استفاده از Historical API برای روزهای گذشته + const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD + const response = await fetch( + `https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran` + ) + + if (!response.ok) { + throw new Error('Failed to fetch historical weather data') + } + + const data = await response.json() + + // ساختار داده برای روزهای گذشته (بدون current و hourly) + weather = { + current: { + temperature: data.daily.temperature_2m_max?.[0] || 0, + humidity: 0, // Historical API رطوبت ندارد + windSpeed: data.daily.wind_speed_10m_max?.[0] || 0, + weatherCode: data.daily.weather_code?.[0] || 0, + }, + hourly: [], // برای گذشته hourly نداریم + daily: [{ + date: data.daily.time?.[0] || dateStr, + tempMax: data.daily.temperature_2m_max?.[0] || 0, + tempMin: data.daily.temperature_2m_min?.[0] || 0, + weatherCode: data.daily.weather_code?.[0] || 0, + precipitation: data.daily.precipitation_sum?.[0] || 0, + precipitationProbability: 0, + uvIndexMax: 0, + sunshineDuration: data.daily.sunshine_duration?.[0] || 0, + windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0, + }] + } + } else { + // استفاده از Forecast API برای امروز و آینده + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7` + ) + + if (!response.ok) { + throw new Error('Failed to fetch weather data') + } + + const data = await response.json() + + // Get only today's hourly data (first 24 hours) + const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({ + time, + temperature: data.hourly.temperature_2m[i], + humidity: data.hourly.relative_humidity_2m[i], + weatherCode: data.hourly.weather_code[i], + precipitation: data.hourly.precipitation[i], + })) + + weather = { + current: { + temperature: data.current.temperature_2m, + humidity: data.current.relative_humidity_2m, + windSpeed: data.current.wind_speed_10m, + weatherCode: data.current.weather_code, + }, + hourly: todayHourly, + daily: data.daily.time.map((date: string, i: number) => ({ + date, + tempMax: data.daily.temperature_2m_max[i], + tempMin: data.daily.temperature_2m_min[i], + weatherCode: data.daily.weather_code[i], + precipitation: data.daily.precipitation_sum[i], + precipitationProbability: data.daily.precipitation_probability_max[i], + uvIndexMax: data.daily.uv_index_max[i], + sunshineDuration: data.daily.sunshine_duration[i], + windSpeedMax: data.daily.wind_speed_10m_max[i], + })) + } + } + + setWeatherData(weather) + } catch (error) { + console.error('Error loading weather:', error) + setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.') + } finally { + setWeatherLoading(false) + } + }, [weatherData, selectedDate]) + + useEffect(() => { + // Reset states when date or device changes + setDailyReport(null) + setWeatherData(null) + setAnalysisError(null) + setWeatherError(null) + loadData() + }, [loadData, deviceId, selectedDate]) + + // Load analysis when switching to analysis tab + useEffect(() => { + if (activeTab === 'analysis') { + loadAnalysis() + } + }, [activeTab, loadAnalysis]) + + // Load weather when switching to weather tab + useEffect(() => { + if (activeTab === 'weather') { + loadWeather() + } + }, [activeTab, loadWeather]) + + const sortedTelemetry = useMemo(() => { + return [...telemetry].sort((a, b) => { + const aTime = a.serverTimestampUtc || a.timestampUtc + const bTime = b.serverTimestampUtc || b.timestampUtc + return new Date(aTime).getTime() - new Date(bTime).getTime() + }) + }, [telemetry]) + + // Data arrays + const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry]) + const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry]) + const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry]) + const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry]) + const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry]) + + // Min/Max calculations + const tempMinMax = useMemo(() => { + const min = Math.min(...temp) + const max = Math.max(...temp) + return { + min: min < 0 ? Math.floor(min / 10) * 10 : 0, + max: max > 40 ? Math.floor(max / 10) * 10 : 40 + } + }, [temp]) + + const luxMinMax = useMemo(() => { + const max = Math.max(...lux) + return { + min: 0, + max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000 + } + }, [lux]) + + // Detect data gaps in the full day data + const dataGaps = useMemo(() => { + const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc) + return detectDataGaps(timestamps, 30) // 30 minutes threshold + }, [sortedTelemetry]) + + // Filtered telemetry for charts based on minute range + const filteredTelemetryForCharts = useMemo(() => { + return sortedTelemetry.filter(t => { + const timestamp = t.serverTimestampUtc || t.timestampUtc + const date = new Date(timestamp) + const minuteOfDay = date.getHours() * 60 + date.getMinutes() + return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute + }) + }, [sortedTelemetry, chartStartMinute, chartEndMinute]) + + // Detect gaps in filtered data + const filteredDataGaps = useMemo(() => { + const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc) + return detectDataGaps(timestamps, 30) + }, [filteredTelemetryForCharts]) + + // Filtered chart labels + const chartLabels = useMemo(() => { + return filteredTelemetryForCharts.map(t => { + const timestamp = t.serverTimestampUtc || t.timestampUtc + const date = new Date(timestamp) + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + const seconds = date.getSeconds().toString().padStart(2, '0') + return `${hours}:${minutes}:${seconds}` + }) + }, [filteredTelemetryForCharts]) + + // Helper function to insert nulls for gaps + const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => { + if (gaps.length === 0 || data.length < 2) return data + + const result: (number | null)[] = [] + + for (let i = 0; i < data.length; i++) { + result.push(data[i]) + + // Check if there's a gap after this point + if (i < data.length - 1) { + const currentTime = new Date(timestamps[i]) + const nextTime = new Date(timestamps[i + 1]) + const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes() + const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes() + + // Find if any gap exists between current and next + const hasGap = gaps.some(gap => + currentMinute <= gap.startMinute && nextMinute >= gap.endMinute + ) + + if (hasGap) { + result.push(null) // Insert null to break the line + } + } + } + + return result + } + + // Filtered data arrays for charts (with gaps as null) + const filteredTimestamps = useMemo(() => + filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc), + [filteredTelemetryForCharts] + ) + + const chartSoil = useMemo(() => { + const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0)) + return insertGapsInData(data, filteredTimestamps, filteredDataGaps) + }, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps]) + + const chartTemp = useMemo(() => { + const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0)) + return insertGapsInData(data, filteredTimestamps, filteredDataGaps) + }, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps]) + + const chartHum = useMemo(() => { + const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0)) + return insertGapsInData(data, filteredTimestamps, filteredDataGaps) + }, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps]) + + const chartGas = useMemo(() => { + const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0)) + return insertGapsInData(data, filteredTimestamps, filteredDataGaps) + }, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps]) + + const chartLux = useMemo(() => { + const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0)) + return insertGapsInData(data, filteredTimestamps, filteredDataGaps) + }, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps]) + + // Min/Max calculations for filtered charts (filter out nulls) + const chartTempMinMax = useMemo(() => { + const validTemps = chartTemp.filter((t): t is number => t !== null) + if (validTemps.length === 0) return { min: 0, max: 40 } + const min = Math.min(...validTemps) + const max = Math.max(...validTemps) + return { + min: min < 0 ? Math.floor(min / 10) * 10 : 0, + max: max > 40 ? Math.floor(max / 10) * 10 : 40 + } + }, [chartTemp]) + + const chartLuxMinMax = useMemo(() => { + const validLux = chartLux.filter((l): l is number => l !== null) + if (validLux.length === 0) return { min: 0, max: 2000 } + const max = Math.max(...validLux) + return { + min: 0, + max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000 + } + }, [chartLux]) + + if (loading) { + return + } + + if (!selectedDate) { + return ( +
+
+ +
تاریخ انتخاب نشده است
+ +
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

+ گزارش روزانه {selectedDate} +

+

+ مشاهده خلاصه و نمودارهای روز +

+
+
+ + + تنظیمات هشدار + +
+
+ + {/* Date Navigation Buttons */} +
+ + + +
+ + {/* Tabs */} +
+
+ {TABS.map(tab => ( + + ))} +
+ +
+ {/* Summary Tab */} + {activeTab === 'summary' && ( + + )} + + {/* Charts Tab */} + {activeTab === 'charts' && ( + + )} + + {/* Weather Tab */} + {activeTab === 'weather' && ( + { + setWeatherData(null) + setWeatherError(null) + loadWeather() + }} + expandedDayIndex={expandedDayIndex} + onDayToggle={setExpandedDayIndex} + selectedDate={selectedDate} + /> + )} + + {/* Analysis Tab */} + {activeTab === 'analysis' && ( + { + setDailyReport(null) + setAnalysisError(null) + loadAnalysis() + }} + /> + )} +
+
+
+
+ ) +} diff --git a/src/app/day-details/page.tsx b/src/app/day-details/page.tsx index c7cd9c3..b755983 100644 --- a/src/app/day-details/page.tsx +++ b/src/app/day-details/page.tsx @@ -1,24 +1,22 @@ "use client" import { useEffect, useState, useMemo } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import { api } from '@/lib/api' import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date' import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react' import Link from 'next/link' import Loading from '@/components/Loading' -function useQueryParam(name: string) { - if (typeof window === 'undefined') return null as string | null - return new URLSearchParams(window.location.search).get(name) -} - const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] export default function DayDetailsPage() { + const router = useRouter() + const searchParams = useSearchParams() const [items, setItems] = useState<{ persianDate: string; count: number }[]>([]) const [loading, setLoading] = useState(true) - const deviceId = Number(useQueryParam('deviceId') ?? '1') - const year = Number(useQueryParam('year') ?? getCurrentPersianYear()) - const month = Number(useQueryParam('month') ?? getCurrentPersianMonth()) + const deviceId = Number(searchParams.get('deviceId') ?? '1') + const year = Number(searchParams.get('year') ?? getCurrentPersianYear()) + const month = Number(searchParams.get('month') ?? getCurrentPersianMonth()) useEffect(() => { setLoading(true) @@ -120,10 +118,14 @@ export default function DayDetailsPage() { const recordCount = dataByDay.get(day) || 0 if (hasData) { + const dayStr = String(day).padStart(2, '0') + const monthStr = String(month).padStart(2, '0') + const dateStr = `${year}/${monthStr}/${dayStr}` + return ( - router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)} className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md" >
{day}
@@ -131,7 +133,7 @@ export default function DayDetailsPage() { {recordCount} - + ) } else { return ( diff --git a/src/app/device-settings/page.tsx b/src/app/device-settings/page.tsx index d6f481f..fa8f6ef 100644 --- a/src/app/device-settings/page.tsx +++ b/src/app/device-settings/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useState, useCallback } from 'react' import { api, DeviceSettingsDto } from '@/lib/api' -import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react' +import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react' import Link from 'next/link' import Loading from '@/components/Loading' @@ -121,13 +121,22 @@ export default function DeviceSettingsPage() { بازگشت به انتخاب دستگاه -
-
- +
+
+
+ +
+

+ تنظیمات {deviceName} +

-

- تنظیمات {deviceName} -

+ + + تنظیمات هشدار +
diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx index 880240f..5e3bb66 100644 --- a/src/app/devices/page.tsx +++ b/src/app/devices/page.tsx @@ -5,6 +5,7 @@ import { api, DeviceDto, PagedResult } from '@/lib/api' import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react' import Link from 'next/link' import Loading from '@/components/Loading' +import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' export default function DevicesPage() { const router = useRouter() @@ -35,8 +36,9 @@ export default function DevicesPage() { if (result.items.length === 0 && page === 1) { setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.') } else if (result.items.length === 1 && result.totalCount === 1 && !search) { - // Single device - redirect to calendar - router.push(`/calendar?deviceId=${result.items[0].id}`) + // Single device - redirect to today's daily report + const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` + router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`) return } else { setPagedResult(result) @@ -190,34 +192,37 @@ export default function DevicesPage() { {pagedResult && pagedResult.items.length > 0 ? ( <>
- {pagedResult.items.map((device) => ( - -
-
- -
-
-

- {device.deviceName} -

-

- {device.location || 'بدون موقعیت'} -

-
- {device.userName} {device.userFamily} + {pagedResult.items.map((device) => { + const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` + return ( + +
+
+ +
+
+

+ {device.deviceName} +

+

+ {device.location || 'بدون موقعیت'} +

+
+ {device.userName} {device.userFamily} +
+
+
+
-
- -
-
-
- - ))} +
+ + ) + })}
{/* Pagination */} diff --git a/src/app/globals.css b/src/app/globals.css index a72a6d1..e188e3e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,4 +35,76 @@ body { font-feature-settings: 'ss01'; font-variant-numeric: persian; font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; +} + +/* Markdown content styles */ +.markdown-content { + line-height: 1.8; + color: #374151; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4 { + color: #1f2937; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.markdown-content h1 { + font-size: 1.5rem; +} + +.markdown-content h2 { + font-size: 1.25rem; +} + +.markdown-content h3 { + font-size: 1.125rem; +} + +.markdown-content h4 { + font-size: 1rem; +} + +.markdown-content p { + margin-bottom: 1em; +} + +.markdown-content ul, +.markdown-content ol { + margin: 0.75em 0; + padding-right: 1.5em; +} + +.markdown-content li { + margin: 0.25em 0; +} + +.markdown-content strong { + font-weight: 700; + color: #1f2937; +} + +.markdown-content code { + background-color: #f3f4f6; + padding: 0.125em 0.375em; + border-radius: 0.25em; + font-size: 0.875em; +} + +.markdown-content blockquote { + border-right: 4px solid #6366f1; + padding-right: 1em; + margin: 1em 0; + color: #6b7280; + font-style: italic; +} + +.markdown-content hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 1.5em 0; } \ No newline at end of file diff --git a/src/app/telemetry/GreenHomeBack.code-workspace b/src/app/telemetry/GreenHomeBack.code-workspace new file mode 100644 index 0000000..e7c8d97 --- /dev/null +++ b/src/app/telemetry/GreenHomeBack.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "../../../../GreenHomeBack" + }, + { + "path": "../../.." + } + ] +} \ No newline at end of file diff --git a/src/app/telemetry/page.tsx b/src/app/telemetry/page.tsx deleted file mode 100644 index ff92c4b..0000000 --- a/src/app/telemetry/page.tsx +++ /dev/null @@ -1,315 +0,0 @@ -"use client" -import { useEffect, useMemo, useState, useCallback, useRef } from 'react' -import { api, TelemetryDto } from '@/lib/api' -import { Card, DashboardGrid } from '@/components/DashboardCards' -import { LineChart, Panel } from '@/components/Charts' -import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date' -import { BarChart3, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react' -import Link from 'next/link' -import Loading from '@/components/Loading' - -// زمان به‌روزرسانی خودکار (به میلی‌ثانیه) - می‌توانید این مقدار را تغییر دهید -const AUTO_REFRESH_INTERVAL = 10 * 1000 // 10 ثانیه - -type TimeRange = 'today' | '1day' | '1hour' | '2hours' | '6hours' | '10hours' - -const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ - { value: 'today', label: 'امروز' }, - { value: '1day', label: '۲۴ ساعت اخیر' }, - { value: '10hours', label: '۱۰ ساعت اخیر' }, - { value: '6hours', label: '۶ ساعت اخیر' }, - { value: '2hours', label: 'دو ساعت اخیر' }, - { value: '1hour', label: 'یک ساعت اخیر' }, -] - -function useQueryParam(name: string) { - if (typeof window === 'undefined') return null as string | null - return new URLSearchParams(window.location.search).get(name) -} - -export default function TelemetryPage() { - const [telemetry, setTelemetry] = useState([]) - const [total, setTotal] = useState(0) - const [loading, setLoading] = useState(true) - const [lastUpdate, setLastUpdate] = useState(null) - const [timeRange, setTimeRange] = useState('1day') - const deviceId = Number(useQueryParam('deviceId') ?? '1') - const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}` - const intervalRef = useRef(null) - - const selectedDate = useMemo(() => { - if (!dateParam) return null - try { - const decodedDate = decodeURIComponent(dateParam) - return decodedDate - } catch (error) { - console.error('Error decoding date parameter:', error) - return null - } - }, [dateParam]) - - // تابع بارگذاری داده‌ها - const loadData = useCallback(async (showLoading = true) => { - if (!selectedDate) { - setLoading(false) - return - } - - if (showLoading) { - setLoading(true) - } - - try { - let startDate: Date - let endDate: Date - - if (timeRange === 'today') { - // برای یک روز، از تاریخ انتخابی استفاده می‌کنیم - const [year, month, day] = selectedDate.split('/').map(Number) - startDate = persianToGregorian(year, month, day) - startDate.setHours(0, 0, 0, 0) - endDate = new Date(startDate) - endDate.setHours(23, 59, 59, 999) - } else { - // برای بازه‌های زمانی دیگر، از زمان فعلی به عقب می‌رویم - endDate = new Date() - const now = new Date() - - switch (timeRange) { - case '1day': - startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) - break - case '1hour': - startDate = new Date(now.getTime() - 60 * 60 * 1000) - break - case '2hours': - startDate = new Date(now.getTime() - 2 * 60 * 60 * 1000) - break - case '6hours': - startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000) - break - case '10hours': - startDate = new Date(now.getTime() - 10 * 60 * 60 * 1000) - break - default: - startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) - } - } - - const startUtc = startDate.toISOString(); - const endUtc = endDate.toISOString(); - - const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 }) - setTelemetry(result.items) - setTotal(result.totalCount) - setLastUpdate(new Date()) - } catch (error) { - console.error('Error loading telemetry:', error) - } finally { - if (showLoading) { - setLoading(false) - } - } - }, [deviceId, selectedDate, timeRange]) - - // بارگذاری اولیه - useEffect(() => { - loadData(true) - }, [loadData]) - - // تنظیم به‌روزرسانی خودکار - useEffect(() => { - if (!selectedDate) return - - // پاک کردن interval قبلی - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - - // تنظیم interval جدید - intervalRef.current = setInterval(() => { - loadData(false) // بدون نمایش loading - }, AUTO_REFRESH_INTERVAL) - - // Cleanup - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - }, [selectedDate, loadData]) - - const sortedTelemetry = useMemo(() => { - return [...telemetry].sort((a, b) => { - const aTime = a.serverTimestampUtc || a.timestampUtc - const bTime = b.serverTimestampUtc || b.timestampUtc - return new Date(aTime).getTime() - new Date(bTime).getTime() - }) - }, [telemetry]) - - // تبدیل timestamp به ساعت (HH:MM:SS) - const labels = useMemo(() => { - return sortedTelemetry.map(t => { - const timestamp = t.serverTimestampUtc || t.timestampUtc - const date = new Date(timestamp) - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') - const seconds = date.getSeconds().toString().padStart(2, '0') - return `${hours}:${minutes}:${seconds}` - }) - }, [sortedTelemetry]) - const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry]) - const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry]) - const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry]) - const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry]) - const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry]) - - - const tempMinMax = useMemo(() => { - const min = Math.min(...temp); - const max = Math.max(...temp); - return { - min: min < 0 ? Math.floor(min / 10) * 10 : 0, - max: max > 40 ? Math.floor(max / 10) * 10 : 40 - } - }, [temp]); - - const luxMinMax = useMemo(() => { - const max = Math.max(...lux); - return { - min: 0, - max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000 - } - }, [lux]); - - - if (loading) { - return - } - - if (!selectedDate) { - return ( -
-
- -
تاریخ انتخاب نشده است
- - - بازگشت به تقویم - -
-
- ) - } - - return ( -
-
- {/* Header */} -
- - - بازگشت به تقویم - -
-
-
- -
-
-

- جزئیات داده‌های {selectedDate} -

- {lastUpdate && ( -

- آخرین به‌روزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')} -

- )} -
-
-
- - -
-
-
- - - - - - - - - -
- - - - - - - - - - - - - - - -
-
-
- ) -} - diff --git a/src/components/Charts.tsx b/src/components/Charts.tsx index aa4df53..af1a074 100644 --- a/src/components/Charts.tsx +++ b/src/components/Charts.tsx @@ -1,4 +1,5 @@ "use client" +import React from 'react' import { Line } from 'react-chartjs-2' import { Chart, @@ -10,8 +11,9 @@ import { Tooltip, Filler } from 'chart.js' +import annotationPlugin from 'chartjs-plugin-annotation' -Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler) +Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler, annotationPlugin) // تابع تبدیل ارقام انگلیسی به فارسی function toPersianDigits(str: string | number): string { @@ -19,7 +21,12 @@ function toPersianDigits(str: string | number): string { return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) } -type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean } +type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean } + +type DataGapAnnotation = { + startIndex: number + endIndex: number +} type Props = { labels: string[] @@ -27,24 +34,102 @@ type Props = { title?: string yAxisMin?: number yAxisMax?: number + dataGaps?: DataGapAnnotation[] // Indices where gaps occur } export function Panel({ title, children }: { title: string; children: React.ReactNode }) { return (
-
-

{title}

+
+

{title}

-
+
{children}
) } -export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) { +export function LineChart({ labels, series, yAxisMin, yAxisMax, dataGaps = [] }: Props) { + // Find gap annotations based on null values in data + const gapAnnotations = React.useMemo(() => { + const annotations: any = {} + let gapCount = 0 + + // Find nulls in the first series data + if (series.length > 0) { + const data = series[0].data + for (let i = 0; i < data.length; i++) { + if (data[i] === null) { + // Find the gap range + const startIdx = Math.max(0, i - 1) + const endIdx = Math.min(data.length - 1, i + 1) + + annotations[`gap-${gapCount++}`] = { + type: 'box' as const, + xMin: startIdx, + xMax: endIdx, + backgroundColor: 'rgba(239, 68, 68, 0.1)', + borderColor: 'rgba(239, 68, 68, 0.3)', + borderWidth: 1, + borderDash: [5, 5], + label: { + display: false + } + } + } + } + } + + return annotations + }, [series]) + + // Calculate hour range and determine which hours to show + const hourConfig = React.useMemo(() => { + const validLabels = labels.filter(l => l) + if (validLabels.length === 0) { + return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) } + } + + const firstTime = validLabels[0] + const lastTime = validLabels[validLabels.length - 1] + + if (!firstTime || !lastTime) { + return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) } + } + + const [firstHour] = firstTime.split(':').map(Number) + const [lastHour] = lastTime.split(':').map(Number) + + // Create array of hours to show (from start to end, one per hour) + const startHour = firstHour + let endHour = lastHour + + // If last hour is the same as first, extend to next hour + if (endHour <= startHour) { + endHour = startHour + 24 + } + + // Create array of hours + const hours: number[] = [] + for (let h = startHour; h <= endHour; h++) { + hours.push(h % 24) + } + + return { startHour, endHour, hours } + }, [labels]) + + // Create map of label index to hour + const labelHours = React.useMemo(() => { + return labels.map(label => { + if (!label) return null + const [hour] = label.split(':').map(Number) + return hour + }) + }, [labels]) + return ( -
+
h === hour) + + // Only show label if this is the first occurrence of this hour + if (firstIndexOfHour !== index) return '' + + // Show only the hour number + return toPersianDigits(hour.toString()) } }, grid: { + display: false // Remove grid lines + }, + border: { display: true, - color: 'rgba(0, 0, 0, 0.05)' + color: 'rgba(0, 0, 0, 0.1)' } }, y: { @@ -118,15 +225,19 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) { ticks: { font: { family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif", - size: 11 + size: 10 }, + padding: 8, callback: function(value) { return toPersianDigits(value.toString()) } }, grid: { + display: false // Remove grid lines + }, + border: { display: true, - color: 'rgba(0, 0, 0, 0.05)' + color: 'rgba(0, 0, 0, 0.1)' } } } diff --git a/src/components/Gauges.tsx b/src/components/Gauges.tsx new file mode 100644 index 0000000..5393186 --- /dev/null +++ b/src/components/Gauges.tsx @@ -0,0 +1,330 @@ +"use client" + +// تابع تبدیل ارقام انگلیسی به فارسی +function toPersianDigits(num: number | string): string { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) +} + +// Temperature Gauge - Semi-circular gauge with blue to red gradient +type TemperatureGaugeProps = { + value: number + min?: number + max?: number +} + +export function TemperatureGauge({ value, min = -20, max = 80 }: TemperatureGaugeProps) { + const range = max - min + const percentage = Math.max(0, Math.min(100, ((value - min) / range) * 100)) + const valueAngle = (percentage / 100) * 180 // 0 to 180 degrees + + // Create gradient stops for temperature (blue to red) + const gradientId = `temp-gradient-${Math.random().toString(36).substr(2, 9)}` + const glowId = `temp-glow-${Math.random().toString(36).substr(2, 9)}` + + // Center point and radius for the arc + const cx = 75 + const cy = 75 + const radius = 50 + const gaugeWidth = 18 + + // Calculate pointer position (triangle pointing inward from outside) + const pointerAngle = (180 - valueAngle) * (Math.PI / 180) + const pointerOuterRadius = radius - gaugeWidth / 2 - 2 + const pointerX = cx + pointerOuterRadius * Math.cos(pointerAngle) + const pointerY = cy - pointerOuterRadius * Math.sin(pointerAngle) + + // Triangle points for the pointer + const triangleSize = 6 + const perpAngle = pointerAngle + Math.PI / 2 + const p1x = pointerX + triangleSize * Math.cos(perpAngle) + const p1y = pointerY - triangleSize * Math.sin(perpAngle) + const p2x = pointerX - triangleSize * Math.cos(perpAngle) + const p2y = pointerY + triangleSize * Math.sin(perpAngle) + const p3x = cx + (pointerOuterRadius - 12) * Math.cos(pointerAngle) + const p3y = cy - (pointerOuterRadius - 12) * Math.sin(pointerAngle) + + // Generate tick marks and labels every 10 degrees + const ticks = [] + for (let temp = min; temp <= max; temp += 10) { + const tickPercentage = ((temp - min) / range) * 100 + const tickAngle = (180 - (tickPercentage / 100) * 180) * (Math.PI / 180) + // خطوط بیرون گیج با فاصله کم - کوتاه‌تر (نصف) + const innerX = cx + (radius + gaugeWidth / 2 + 3) * Math.cos(tickAngle) + const innerY = cy - (radius + gaugeWidth / 2 + 3) * Math.sin(tickAngle) + const outerX = cx + (radius + gaugeWidth / 2 + 6) * Math.cos(tickAngle) + const outerY = cy - (radius + gaugeWidth / 2 + 6) * Math.sin(tickAngle) + const labelX = cx + (radius + gaugeWidth / 2 + 14) * Math.cos(tickAngle) + const labelY = cy - (radius + gaugeWidth / 2 + 14) * Math.sin(tickAngle) + + ticks.push({ temp, outerX, outerY, innerX, innerY, labelX, labelY }) + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + + {/* Colored arc (full gradient) */} + + + {/* Small rounded caps at the ends */} + + + + {/* Tick marks and labels */} + {ticks.map(({ temp, outerX, outerY, innerX, innerY, labelX, labelY }) => ( + + + + {toPersianDigits(temp)} + + + ))} + + {/* Triangle pointer pointing inward */} + + + + {/* Center value */} +
+ {toPersianDigits(value.toFixed(1))} + °C +
+
+ ) +} + +// Humidity Gauge - Water droplet style fill +type HumidityGaugeProps = { + value: number + type?: 'air' | 'soil' +} + +export function HumidityGauge({ value, type = 'air' }: HumidityGaugeProps) { + const percentage = Math.max(0, Math.min(100, value)) + const fillColor = type === 'air' ? '#3b82f6' : '#16a34a' + const bgColor = type === 'air' ? '#dbeafe' : '#dcfce7' + const textColor = type === 'air' ? '#1e40af' : '#166534' + const gradientId = `humidity-gradient-${Math.random().toString(36).substr(2, 9)}` + const clipId = `humidity-clip-${Math.random().toString(36).substr(2, 9)}` + + return ( +
+ + + + + + + + + + + + {/* Background droplet */} + + + {/* Filled portion */} + + + {/* Percentage text inside droplet - larger and with stroke for visibility */} + + {toPersianDigits(Math.round(percentage))}% + + +
+ ) +} + +// Light/Lux Gauge - Sun rays style +type LuxGaugeProps = { + value: number + max?: number +} + +export function LuxGauge({ value, max = 2000 }: LuxGaugeProps) { + const percentage = Math.max(0, Math.min(100, (value / max) * 100)) + const numRays = 12 + const activeRays = Math.round((percentage / 100) * numRays) + + return ( +
+ + {/* Rays */} + {Array.from({ length: numRays }).map((_, i) => { + const angle = (i * 360) / numRays - 90 + const isActive = i < activeRays + const x1 = 40 + 22 * Math.cos((angle * Math.PI) / 180) + const y1 = 40 + 22 * Math.sin((angle * Math.PI) / 180) + const x2 = 40 + 35 * Math.cos((angle * Math.PI) / 180) + const y2 = 40 + 35 * Math.sin((angle * Math.PI) / 180) + + return ( + + ) + })} + + {/* Center circle */} + 0 ? '#fbbf24' : '#f3f4f6'} + className="transition-all duration-300" + /> + + {/* Inner glow */} + {percentage > 20 && ( + + )} + + + {/* Value below */} +
+ {toPersianDigits(Math.round(value))} + Lux +
+
+ ) +} + +// Gas/CO Gauge - Circular progress with warning colors +type GasGaugeProps = { + value: number + max?: number +} + +export function GasGauge({ value, max = 100 }: GasGaugeProps) { + const percentage = Math.max(0, Math.min(100, (value / max) * 100)) + const circumference = 2 * Math.PI * 35 + const strokeDashoffset = circumference - (percentage / 100) * circumference + + // Color based on level (green -> yellow -> orange -> red) + const getColor = () => { + if (percentage < 25) return '#22c55e' + if (percentage < 50) return '#eab308' + if (percentage < 75) return '#f97316' + return '#ef4444' + } + + const color = getColor() + + return ( +
+ + {/* Background circle */} + + + {/* Progress circle */} + + + + {/* Center content */} +
+ {toPersianDigits(Math.round(value))} + ppm +
+
+ ) +} + diff --git a/src/components/MiniChart.tsx b/src/components/MiniChart.tsx new file mode 100644 index 0000000..3b68946 --- /dev/null +++ b/src/components/MiniChart.tsx @@ -0,0 +1,70 @@ +"use client" +import { Line } from 'react-chartjs-2' +import { + Chart, + LineElement, + CategoryScale, + LinearScale, + PointElement, + Filler +} from 'chart.js' + +Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Filler) + +type MiniLineChartProps = { + data: number[] + color: string +} + +export function MiniLineChart({ data, color }: MiniLineChartProps) { + // Sample data if too many points (for performance) + const sampledData = data.length > 50 + ? data.filter((_, i) => i % Math.ceil(data.length / 50) === 0) + : data + + // Create slightly darker color for fill + const fillColor = `${color}33` // 20% opacity + + return ( +
+ i.toString()), + datasets: [{ + data: sampledData, + borderColor: color, + backgroundColor: fillColor, + fill: true, + tension: 0.4, + pointRadius: 0, + borderWidth: 2 + }] + }} + options={{ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { enabled: false } + }, + scales: { + x: { + display: false, + grid: { display: false } + }, + y: { + display: false, + grid: { display: false } + } + }, + interaction: { + intersect: false, + mode: 'nearest' + }, + events: [] // Disable all interactions + }} + /> +
+ ) +} + diff --git a/src/components/daily-report/AnalysisTab.tsx b/src/components/daily-report/AnalysisTab.tsx new file mode 100644 index 0000000..518c4aa --- /dev/null +++ b/src/components/daily-report/AnalysisTab.tsx @@ -0,0 +1,80 @@ +import { Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import { DailyReportDto } from '@/lib/api' +import { toPersianDigits } from './utils' + +type AnalysisTabProps = { + loading: boolean + error: string | null + dailyReport: DailyReportDto | null + onRetry: () => void +} + +export function AnalysisTab({ loading, error, dailyReport, onRetry }: AnalysisTabProps) { + if (loading) { + return ( +
+ +

در حال دریافت تحلیل...

+
+ ) + } + + if (error) { + return ( +
+
+ +
+

{error}

+ +
+ ) + } + + if (!dailyReport) { + return ( +
+

تحلیلی برای نمایش وجود ندارد

+
+ ) + } + + return ( +
+ {/* Report Info */} +
+
+
+

تعداد رکوردها

+

{toPersianDigits(dailyReport.recordCount)}

+
+
+

رکوردهای نمونه

+

{toPersianDigits(dailyReport.sampledRecordCount)}

+
+
+

توکن‌های مصرفی

+

{toPersianDigits(dailyReport.totalTokens)}

+
+
+

منبع

+

{dailyReport.fromCache ? '💾 کش' : '🆕 جدید'}

+
+
+
+ + {/* Analysis Content */} +
+ {dailyReport.analysis} +
+
+ ) +} + diff --git a/src/components/daily-report/ChartsTab.tsx b/src/components/daily-report/ChartsTab.tsx new file mode 100644 index 0000000..c5d4b19 --- /dev/null +++ b/src/components/daily-report/ChartsTab.tsx @@ -0,0 +1,104 @@ +import { BarChart3 } from 'lucide-react' +import { LineChart, Panel } from '@/components/Charts' +import { TimeRangeSelector } from './TimeRangeSelector' +import { DataGap } from './utils' + +type ChartsTabProps = { + chartStartMinute: number + chartEndMinute: number + onStartMinuteChange: (minute: number) => void + onEndMinuteChange: (minute: number) => void + labels: string[] + soil: (number | null)[] + humidity: (number | null)[] + temperature: (number | null)[] + lux: (number | null)[] + gas: (number | null)[] + tempMinMax: { min: number; max: number } + luxMinMax: { min: number; max: number } + totalRecords: number + dataGaps?: DataGap[] +} + +export function ChartsTab({ + chartStartMinute, + chartEndMinute, + onStartMinuteChange, + onEndMinuteChange, + labels, + soil, + humidity, + temperature, + lux, + gas, + tempMinMax, + luxMinMax, + totalRecords, + dataGaps = [] +}: ChartsTabProps) { + return ( +
+ {/* Time Range Selector */} + + + {/* Charts Grid */} + {totalRecords === 0 ? ( +
+ +

داده‌ای برای این بازه زمانی موجود نیست

+
+ ) : ( +
+ + + + + + + + + + + + + + + +
+ )} +
+ ) +} + diff --git a/src/components/daily-report/SummaryCard.tsx b/src/components/daily-report/SummaryCard.tsx new file mode 100644 index 0000000..3582a13 --- /dev/null +++ b/src/components/daily-report/SummaryCard.tsx @@ -0,0 +1,82 @@ +import { TrendingUp, TrendingDown } from 'lucide-react' +import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges' +import { MiniLineChart } from '@/components/MiniChart' +import { paramConfig, toPersianDigits } from './utils' + +type SummaryCardProps = { + param: string + currentValue: number + minValue: number + maxValue: number + data: number[] +} + +export function SummaryCard({ param, currentValue, minValue, maxValue, data }: SummaryCardProps) { + const config = paramConfig[param] + if (!config) return null + + // Render the appropriate gauge based on parameter type + const renderGauge = () => { + switch (param) { + case 'temperature': + return + case 'humidity': + return + case 'soil': + return + case 'lux': + return + case 'gas': + return + default: + return null + } + } + + return ( +
+ {/* Header */} +
+

{config.title}

+
+ + {/* Main Content - Gauge on left, Stats on right */} +
+
+ {/* Gauge on left */} +
+ {renderGauge()} +
+ + {/* Stats on right - stacked */} +
+
+ +
+ حداکثر + + {toPersianDigits(maxValue.toFixed(1))} {config.unit} + +
+
+
+ +
+ حداقل + + {toPersianDigits(minValue.toFixed(1))} {config.unit} + +
+
+
+
+
+ + {/* Mini Chart */} +
+ +
+
+ ) +} + diff --git a/src/components/daily-report/SummaryTab.tsx b/src/components/daily-report/SummaryTab.tsx new file mode 100644 index 0000000..a6a9004 --- /dev/null +++ b/src/components/daily-report/SummaryTab.tsx @@ -0,0 +1,77 @@ +import { SummaryCard } from './SummaryCard' + +type SummaryTabProps = { + temperature: { + current: number + min: number + max: number + data: number[] + } + humidity: { + current: number + min: number + max: number + data: number[] + } + soil: { + current: number + min: number + max: number + data: number[] + } + gas: { + current: number + min: number + max: number + data: number[] + } + lux: { + current: number + min: number + max: number + data: number[] + } +} + +export function SummaryTab({ temperature, humidity, soil, gas, lux }: SummaryTabProps) { + return ( +
+ + + + + +
+ ) +} + diff --git a/src/components/daily-report/TimeRangeSelector.tsx b/src/components/daily-report/TimeRangeSelector.tsx new file mode 100644 index 0000000..49661c2 --- /dev/null +++ b/src/components/daily-report/TimeRangeSelector.tsx @@ -0,0 +1,265 @@ +import { BarChart3, AlertTriangle } from 'lucide-react' +import { toPersianDigits, DataGap } from './utils' + +type TimeRangeSelectorProps = { + startMinute: number // دقیقه از نیمه شب (0-1439) + endMinute: number // دقیقه از نیمه شب (0-1439) + onStartMinuteChange: (minute: number) => void + onEndMinuteChange: (minute: number) => void + totalRecords: number + dataGaps?: DataGap[] // گپ‌های داده +} + +// محاسبه زمان طلوع و غروب خورشید برای قم +// عرض جغرافیایی: 34.6416° شمالی، طول جغرافیایی: 50.8746° شرقی +function calculateSunTimes() { + const latitude = 34.6416 + const now = new Date() + const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000) + + // محاسبه انحراف خورشید (Solar Declination) + const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10)) + + // محاسبه زاویه ساعتی طلوع (Hour Angle) + const latRad = latitude * Math.PI / 180 + const decRad = declination * Math.PI / 180 + const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad) + + // در صورتی که خورشید طلوع/غروب می‌کند + if (Math.abs(cosHourAngle) <= 1) { + const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI + + // زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق) + const sunriseDecimal = 12 - hourAngle / 15 + (50.8746 / 15 - 3.5) // تصحیح برای طول جغرافیایی و منطقه زمانی ایران + const sunsetDecimal = 12 + hourAngle / 15 + (50.8746 / 15 - 3.5) + + // تبدیل به ساعت و دقیقه + const sunriseHour = Math.floor(sunriseDecimal) + const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60) + + const sunsetHour = Math.floor(sunsetDecimal) + const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60) + + return { + sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal }, + sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal } + } + } + + // مقادیر پیش‌فرض + return { + sunrise: { hour: 6, minute: 0, decimal: 6 }, + sunset: { hour: 18, minute: 0, decimal: 18 } + } +} + +export function TimeRangeSelector({ + startMinute, + endMinute, + onStartMinuteChange, + onEndMinuteChange, + totalRecords, + dataGaps = [] +}: TimeRangeSelectorProps) { + const { sunrise, sunset } = calculateSunTimes() + + // تبدیل دقیقه به ساعت برای نمایش + const startHour = Math.floor(startMinute / 60) + const startMin = startMinute % 60 + const endHour = Math.floor(endMinute / 60) + const endMin = endMinute % 60 + + // محاسبه موقعیت دقیق با دقیقه (از 0 تا 24 ساعت) + const sunrisePosition = sunrise.decimal + const sunsetPosition = sunset.decimal + + // محاسبه درصد موقعیت برای نمایش (0 ساعت در راست، 1440 دقیقه در چپ) + const sunrisePercent = ((1439 - (sunrisePosition * 60)) / 1439) * 100 + const sunsetPercent = ((1439 - (sunsetPosition * 60)) / 1439) * 100 + + return ( +
+ {/* Header */} +
+
+ محدوده زمانی + {dataGaps.length > 0 && ( +
+ + {toPersianDigits(dataGaps.length)} گپ در داده‌ها +
+ )} +
+ +
+ + {/* Timeline Selector */} +
+ {/* Track background */} +
+ {/* Sunrise dashed line */} +
+ + {/* Sunset dashed line */} +
+ +
+ طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))} +
+ +
+
+ +
+
+ + {/* Data gaps visualization */} + {dataGaps.map((gap, idx) => { + const gapStartPercent = ((1439 - gap.startMinute) / 1439) * 100 + const gapEndPercent = ((1439 - gap.endMinute) / 1439) * 100 + const gapWidth = gapStartPercent - gapEndPercent + const gapHours = Math.floor(gap.durationMinutes / 60) + const gapMins = gap.durationMinutes % 60 + + return ( +
+ {/* Gap area */} +
+ {/* Warning icon in gap */} +
+ +
+
+ + {/* Gap tooltip */} + {gapWidth > 5 && ( +
+ گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))} +
+ )} +
+ ) + })} + +
+ غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))} +
+ + {/* Hour markers inside track */} + {[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => ( +
+
+ + {toPersianDigits(hour.toString().padStart(2, '0'))} + +
+
+
+ ))} +
+ + {/* Start time label - above handle */} +
+
+ {toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))} +
+
+ + {/* End time label - above handle */} +
+
+ {toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))} +
+
+ + {/* Range inputs container */} +
+ {/* Start time slider */} + { + const val = 1439 - Number(e.target.value) + if (val <= endMinute) onStartMinuteChange(val) + }} + className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto" + /> + + {/* End time slider */} + { + const val = 1439 - Number(e.target.value) + if (val >= startMinute) onEndMinuteChange(val) + }} + className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto" + /> +
+
+ + {/* Info section */} +
+
+ + {toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))} + + بازه انتخاب شده +
+
+ {totalRecords > 0 + ? <>{toPersianDigits(totalRecords)} رکورد + : <>بدون رکورد + } +
+
+
+ ) +} + diff --git a/src/components/daily-report/WeatherTab.tsx b/src/components/daily-report/WeatherTab.tsx new file mode 100644 index 0000000..274495b --- /dev/null +++ b/src/components/daily-report/WeatherTab.tsx @@ -0,0 +1,516 @@ +import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react' +import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.' +import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' + +type WeatherTabProps = { + loading: boolean + error: string | null + weatherData: WeatherData | null + onRetry: () => void + expandedDayIndex: number | null + onDayToggle: (index: number | null) => void + selectedDate: string | null // Persian date in format "yyyy/MM/dd" +} + +export function WeatherTab({ + loading, + error, + weatherData, + onRetry, + expandedDayIndex, + onDayToggle, + selectedDate +}: WeatherTabProps) { + // Check if selected date is today by comparing Persian dates + const isToday = (() => { + if (!selectedDate) return true + + try { + // Get today's Persian date + const todayYear = getCurrentPersianYear() + const todayMonth = getCurrentPersianMonth() + const todayDay = getCurrentPersianDay() + const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}` + + // Normalize selected date format + const [y, m, d] = selectedDate.split('/').map(s => s.trim()) + const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}` + + return normalizedSelected === todayPersian + } catch (e) { + console.error('Error checking if today:', e) + return true + } + })() + + if (loading) { + return ( +
+ +

در حال دریافت اطلاعات آب و هوا...

+
+ ) + } + + if (error) { + return ( +
+
+ +
+

{error}

+ +
+ ) + } + + if (!weatherData) { + return null + } + + const alerts = getGreenhouseAlerts(weatherData) + + return ( +
+ {/* Location Header */} +
+
+ + قم، ایران +
+ + {isToday ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'} + +
+ + {/* Greenhouse Alerts - Only for today */} + {isToday && alerts.length > 0 && ( +
+

🌱 هشدارها و توصیه‌های گلخانه

+ {alerts.map((alert, index) => ( +
+

{alert.title}

+

{alert.description}

+
+ ))} +
+ )} + + {/* Today's Status Card */} +
+
+ {/* Current Weather Header - Only for today */} + {isToday && ( +
+
+
+

🌡️ الان

+
+ + {toPersianDigits(Math.round(weatherData.current.temperature))} + + درجه +
+
+
+ {(() => { + const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon + return + })()} +

{getWeatherInfo(weatherData.current.weatherCode).description}

+
+
+
+ )} + + {/* Past Date Header */} + {!isToday && ( +
+
+

📅 وضعیت آب و هوای روز

+

{selectedDate}

+
+
+ )} + + {/* Status Grid */} +
+ {/* Temperature Card */} +
35 ? 'bg-red-100 border-2 border-red-300' : + 'bg-green-100 border-2 border-green-300' + }`}> +
+
35 ? 'bg-red-500' : + 'bg-green-500' + }`}> + +
+
+

{isToday ? 'دمای امروز' : 'دمای روز'}

+

35 ? 'text-red-600' : + 'text-green-600' + }`}> + {weatherData.daily[0]?.tempMin < 5 ? '❄️ سرد!' : + weatherData.daily[0]?.tempMax > 35 ? '🔥 گرم!' : + '✅ مناسب'} +

+
+
+
+
+

🌙 شب

+

{toPersianDigits(Math.round(weatherData.daily[0]?.tempMin || 0))}°

+
+
+
+

☀️ روز

+

{toPersianDigits(Math.round(weatherData.daily[0]?.tempMax || 0))}°

+
+
+
+ + {/* Rain Card */} + {isToday ? ( + /* Forecast: احتمال بارش */ +
60 ? 'bg-blue-100 border-2 border-blue-300' : + (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-50 border-2 border-sky-200' : + 'bg-amber-50 border-2 border-amber-200' + }`}> +
+
60 ? 'bg-blue-500' : + (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-400' : + 'bg-amber-400' + }`}> + {(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? + : + + } +
+
+

بارش

+

60 ? 'text-blue-600' : + (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'text-sky-600' : + 'text-amber-600' + }`}> + {(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? '🌧️ باران می‌آید' : + (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? '🌦️ شاید ببارد' : + '☀️ خشک است'} +

+
+
+
+

{toPersianDigits(weatherData.daily[0]?.precipitationProbability || 0)}%

+

احتمال بارش

+
+
+ ) : ( + /* Historical: میزان بارش واقعی */ +
5 ? 'bg-blue-100 border-2 border-blue-300' : + (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-50 border-2 border-sky-200' : + 'bg-amber-50 border-2 border-amber-200' + }`}> +
+
5 ? 'bg-blue-500' : + (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' : + 'bg-amber-400' + }`}> + {(weatherData.daily[0]?.precipitation || 0) > 0 ? + : + + } +
+
+

بارش

+

5 ? 'text-blue-600' : + (weatherData.daily[0]?.precipitation || 0) > 0 ? 'text-sky-600' : + 'text-amber-600' + }`}> + {(weatherData.daily[0]?.precipitation || 0) > 5 ? '🌧️ بارش زیاد' : + (weatherData.daily[0]?.precipitation || 0) > 0 ? '🌦️ بارش کم' : + '☀️ بدون بارش'} +

+
+
+
+

{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}

+

میلی‌متر بارش

+
+
+ )} + + {/* Sunlight Card */} +
+
+
+ +
+
+

نور آفتاب

+

+ {(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' : + (weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' : + '☁️ کم‌آفتاب'} +

+
+
+
+
+

{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}

+

ساعت آفتاب

+
+
+

{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}

+

شاخص UV

+
+
+
+ + {/* Wind & Humidity Card */} +
+
+
+ +
+
+

باد و رطوبت

+

+ {(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' : + (weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' : + '😌 آرام'} +

+
+
+
+
+

{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}

+

کیلومتر/ساعت باد

+
+
+

{toPersianDigits(weatherData.current.humidity)}%

+

رطوبت هوا

+
+
+
+
+
+ + {/* Hourly Forecast - Only for today */} + {isToday && ( +
+
+

+ 🕐 وضعیت ساعت به ساعت امروز +

+
+ +
+
+
+ {weatherData.hourly.map((hour, index) => { + const hourNum = new Date(hour.time).getHours() + const isNow = hourNum === new Date().getHours() + const IconComponent = getWeatherInfo(hour.weatherCode).icon + const isHot = hour.temperature > 35 + const isCold = hour.temperature < 10 + + return ( +
+

+ {isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`} +

+ +
+ +
+ +

+ {toPersianDigits(Math.round(hour.temperature))}° +

+ +
+ + {toPersianDigits(hour.humidity)}% +
+ + {hour.precipitation > 0 && ( +
+ 🌧️ {toPersianDigits(hour.precipitation.toFixed(1))} +
+ )} +
+ ) + })} +
+
+

👈 برای دیدن ساعت‌های بیشتر به چپ بکشید

+
+
+ )} +
+ + {/* 7-Day Forecast - Only for today */} + {isToday && ( +
+

+ + پیش‌بینی ۷ روز آینده +

+
+ {weatherData.daily.map((day, index) => { + const weatherInfo = getWeatherInfo(day.weatherCode) + const IconComponent = weatherInfo.icon + const isToday = index === 0 + const hasFrost = day.tempMin < 5 + const hasHeat = day.tempMax > 35 + const isExpanded = expandedDayIndex === index + + return ( +
+ + + {isExpanded && ( +
+
+
+
+ + دما +
+

{toPersianDigits(Math.round(day.tempMax))}°

+

حداکثر

+

{toPersianDigits(Math.round(day.tempMin))}°

+

حداقل

+
+
+
+ + بارش +
+

{toPersianDigits(day.precipitationProbability)}%

+

احتمال

+

{toPersianDigits(day.precipitation.toFixed(1))}

+

میلی‌متر

+
+
+
+ + ساعات آفتابی +
+

{toPersianDigits(Math.round(day.sunshineDuration / 3600))}

+

ساعت

+

{toPersianDigits(Math.round(day.uvIndexMax))}

+

UV Index

+
+
+
+ + باد +
+

{toPersianDigits(Math.round(day.windSpeedMax))}

+

کیلومتر/ساعت

+
+
+
+ )} +
+ ) + })} +
+
+ )} +
+ ) +} + diff --git a/src/components/daily-report/index.ts b/src/components/daily-report/index.ts new file mode 100644 index 0000000..1c93717 --- /dev/null +++ b/src/components/daily-report/index.ts @@ -0,0 +1,10 @@ +export { SummaryCard } from './SummaryCard' +export { SummaryTab } from './SummaryTab' +export { TimeRangeSelector } from './TimeRangeSelector' +export { ChartsTab } from './ChartsTab' +export { WeatherTab } from './WeatherTab' +export { AnalysisTab } from './AnalysisTab' +export * from './types' +export * from './utils' +export * from './weather-helpers' + diff --git a/src/components/daily-report/types.ts b/src/components/daily-report/types.ts new file mode 100644 index 0000000..0aff828 --- /dev/null +++ b/src/components/daily-report/types.ts @@ -0,0 +1,43 @@ +export type TabType = 'summary' | 'charts' | 'weather' | 'analysis' + +export type WeatherData = { + current: { + temperature: number + humidity: number + windSpeed: number + weatherCode: number + } + hourly: { + time: string + temperature: number + humidity: number + weatherCode: number + precipitation: number + }[] + daily: { + date: string + tempMax: number + tempMin: number + weatherCode: number + precipitation: number + precipitationProbability: number + uvIndexMax: number + sunshineDuration: number // in seconds + windSpeedMax: number + }[] +} + +export type GreenhouseAlert = { + type: 'danger' | 'warning' | 'info' | 'success' + title: string + description: string + icon: React.ComponentType<{ className?: string }> +} + +export const TABS: { value: TabType; label: string }[] = [ + { value: 'summary', label: 'خلاصه' }, + { value: 'charts', label: 'گزارش نموداری' }, + { value: 'weather', label: 'وضعیت آب و هوا' }, + { value: 'analysis', label: 'تحلیل' }, +] + diff --git a/src/components/daily-report/utils.ts b/src/components/daily-report/utils.ts new file mode 100644 index 0000000..90caaee --- /dev/null +++ b/src/components/daily-report/utils.ts @@ -0,0 +1,171 @@ +import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react' + +// Format date to yyyy/MM/dd +export function formatPersianDate(year: number, month: number, day: number): string { + const mm = month.toString().padStart(2, '0') + const dd = day.toString().padStart(2, '0') + return `${year}/${mm}/${dd}` +} + +// Ensure date string is in yyyy/MM/dd format +export function ensureDateFormat(dateStr: string): string { + const parts = dateStr.split('/') + if (parts.length !== 3) return dateStr + const [year, month, day] = parts.map(Number) + return formatPersianDate(year, month, day) +} + +// تابع تبدیل ارقام انگلیسی به فارسی +export function toPersianDigits(num: number | string): string { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)]) +} + +// Weather code to description and icon mapping +export const weatherCodeMap: Record }> = { + 0: { description: 'آسمان صاف', icon: Sun }, + 1: { description: 'عمدتاً صاف', icon: Sun }, + 2: { description: 'نیمه ابری', icon: Cloud }, + 3: { description: 'ابری', icon: Cloud }, + 45: { description: 'مه', icon: CloudFog }, + 48: { description: 'مه یخ‌زده', icon: CloudFog }, + 51: { description: 'نم‌نم باران', icon: CloudRain }, + 53: { description: 'نم‌نم باران', icon: CloudRain }, + 55: { description: 'نم‌نم باران شدید', icon: CloudRain }, + 61: { description: 'باران خفیف', icon: CloudRain }, + 63: { description: 'باران متوسط', icon: CloudRain }, + 65: { description: 'باران شدید', icon: CloudRain }, + 71: { description: 'برف خفیف', icon: CloudSnow }, + 73: { description: 'برف متوسط', icon: CloudSnow }, + 75: { description: 'برف شدید', icon: CloudSnow }, + 77: { description: 'دانه برف', icon: CloudSnow }, + 80: { description: 'رگبار خفیف', icon: CloudRain }, + 81: { description: 'رگبار متوسط', icon: CloudRain }, + 82: { description: 'رگبار شدید', icon: CloudRain }, + 85: { description: 'بارش برف خفیف', icon: CloudSnow }, + 86: { description: 'بارش برف شدید', icon: CloudSnow }, + 95: { description: 'رعد و برق', icon: CloudLightning }, + 96: { description: 'رعد و برق با تگرگ', icon: CloudLightning }, + 99: { description: 'رعد و برق شدید', icon: CloudLightning }, +} + +export function getWeatherInfo(code: number) { + return weatherCodeMap[code] || { description: 'نامشخص', icon: Cloud } +} + +// Persian day names +export const persianDayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'] + +export function getPersianDayName(dateStr: string): string { + const date = new Date(dateStr) + return persianDayNames[date.getDay()] +} + +// Card colors configuration +export const paramConfig: Record = { + temperature: { + title: 'دما', + unit: '°C', + bgColor: 'bg-gradient-to-br from-rose-50 to-red-100', + chartColor: '#ef4444', + }, + humidity: { + title: 'رطوبت هوا', + unit: '%', + bgColor: 'bg-gradient-to-br from-sky-50 to-blue-100', + chartColor: '#3b82f6', + }, + soil: { + title: 'رطوبت خاک', + unit: '%', + bgColor: 'bg-gradient-to-br from-emerald-50 to-green-100', + chartColor: '#16a34a', + }, + gas: { + title: 'گاز CO', + unit: 'ppm', + bgColor: 'bg-gradient-to-br from-slate-50 to-gray-100', + chartColor: '#f59e0b', + }, + lux: { + title: 'نور', + unit: 'Lux', + bgColor: 'bg-gradient-to-br from-amber-50 to-yellow-100', + chartColor: '#a855f7', + }, +} + +// Data gap detection +export type DataGap = { + startMinute: number // دقیقه از نیمه شب + endMinute: number // دقیقه از نیمه شب + durationMinutes: number +} + +// تشخیص گپ‌های داده (شکاف‌های زمانی بدون داده) +export function detectDataGaps(timestamps: string[], gapThresholdMinutes: number = 30): DataGap[] { + if (timestamps.length < 2) return [] + + const gaps: DataGap[] = [] + + for (let i = 0; i < timestamps.length - 1; i++) { + const current = new Date(timestamps[i]) + const next = new Date(timestamps[i + 1]) + + const diffMs = next.getTime() - current.getTime() + const diffMinutes = diffMs / (1000 * 60) + + if (diffMinutes > gapThresholdMinutes) { + const startMinute = current.getHours() * 60 + current.getMinutes() + const endMinute = next.getHours() * 60 + next.getMinutes() + + gaps.push({ + startMinute, + endMinute, + durationMinutes: Math.round(diffMinutes) + }) + } + } + + return gaps +} + +// افزودن null برای گپ‌ها در داده‌های نمودار +export function fillGapsWithNull( + data: T[], + timestamps: string[], + gaps: DataGap[] +): (T | null)[] { + if (gaps.length === 0) return data + + const result: (T | null)[] = [] + let gapIndex = 0 + + for (let i = 0; i < data.length; i++) { + result.push(data[i]) + + // اگر داده بعدی وجود دارد و ما در میانه یک گپ هستیم + if (i < data.length - 1 && gapIndex < gaps.length) { + const currentDate = new Date(timestamps[i]) + const nextDate = new Date(timestamps[i + 1]) + const currentMinute = currentDate.getHours() * 60 + currentDate.getMinutes() + const nextMinute = nextDate.getHours() * 60 + nextDate.getMinutes() + + const gap = gaps[gapIndex] + + // اگر این گپ بین دو نقطه فعلی است، یک null اضافه کن + if (currentMinute <= gap.startMinute && nextMinute >= gap.endMinute) { + result.push(null) + gapIndex++ + } + } + } + + return result +} + diff --git a/src/components/daily-report/weather-helpers.ts b/src/components/daily-report/weather-helpers.ts new file mode 100644 index 0000000..1697cdd --- /dev/null +++ b/src/components/daily-report/weather-helpers.ts @@ -0,0 +1,111 @@ +import { Thermometer, Sun, Droplets, Wind, Leaf, AlertTriangle } from 'lucide-react' +import { WeatherData, GreenhouseAlert } from './types' +import { toPersianDigits } from './utils' + +// Qom coordinates +export const QOM_LAT = 34.6416 +export const QOM_LON = 50.8746 + +// Greenhouse-specific recommendations +export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] { + const alerts: GreenhouseAlert[] = [] + const today = weather.daily[0] + + // Frost warning + if (today.tempMin < 5) { + alerts.push({ + type: 'danger', + title: '⚠️ هشدار یخ‌زدگی', + description: `دمای حداقل امشب ${toPersianDigits(Math.round(today.tempMin))}°C پیش‌بینی شده. سیستم گرمایش را فعال کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`, + icon: Thermometer + }) + } + + // Heat stress warning + if (today.tempMax > 35) { + alerts.push({ + type: 'danger', + title: '🌡️ هشدار گرمای شدید', + description: `دمای حداکثر ${toPersianDigits(Math.round(today.tempMax))}°C پیش‌بینی شده. سایه‌بان‌ها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`, + icon: Sun + }) + } + + // High UV warning + if (today.uvIndexMax > 8) { + alerts.push({ + type: 'warning', + title: '☀️ شاخص UV بالا', + description: `شاخص UV ${toPersianDigits(Math.round(today.uvIndexMax))} است. برای گیاهان حساس به نور از سایه‌بان استفاده کنید.`, + icon: Sun + }) + } + + // Strong wind warning + if (today.windSpeedMax > 40) { + alerts.push({ + type: 'warning', + title: '💨 باد شدید', + description: `سرعت باد به ${toPersianDigits(Math.round(today.windSpeedMax))} کیلومتر بر ساعت می‌رسد. دریچه‌ها و پنجره‌ها را ببندید و سازه را بررسی کنید.`, + icon: Wind + }) + } + + // Rain/precipitation warning + if (today.precipitation > 10) { + alerts.push({ + type: 'info', + title: '🌧️ بارش قابل توجه', + description: `بارش ${toPersianDigits(Math.round(today.precipitation))} میلی‌متر پیش‌بینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`, + icon: Droplets + }) + } + + // Optimal conditions + if (today.tempMin >= 10 && today.tempMax <= 28 && today.windSpeedMax < 30 && today.precipitation < 5) { + alerts.push({ + type: 'success', + title: '✅ شرایط مناسب', + description: 'شرایط آب و هوایی امروز برای رشد گیاهان عالی است. می‌توانید تهویه طبیعی را افزایش دهید.', + icon: Leaf + }) + } + + return alerts +} + +// Get irrigation recommendation +export function getIrrigationRecommendation(weather: WeatherData): { level: string; color: string; description: string } { + const today = weather.daily[0] + + if (today.precipitationProbability > 60) { + return { level: 'کم', color: 'text-blue-600', description: 'به دلیل احتمال بارش، آبیاری را کاهش دهید' } + } + if (today.tempMax > 35) { + return { level: 'زیاد', color: 'text-red-600', description: 'به دلیل گرمای شدید، آبیاری بیشتری لازم است' } + } + if (today.tempMax > 28) { + return { level: 'متوسط-زیاد', color: 'text-orange-600', description: 'آبیاری در ساعات صبح و عصر توصیه می‌شود' } + } + return { level: 'متوسط', color: 'text-green-600', description: 'آبیاری معمول کافی است' } +} + +// Get ventilation recommendation +export function getVentilationRecommendation(weather: WeatherData): { level: string; color: string; description: string } { + const today = weather.daily[0] + + if (today.windSpeedMax > 40) { + return { level: 'بسته', color: 'text-red-600', description: 'به دلیل باد شدید، دریچه‌ها را ببندید' } + } + if (today.tempMax > 30) { + return { level: 'حداکثر', color: 'text-orange-600', description: 'تهویه کامل برای کاهش دما ضروری است' } + } + if (today.tempMax > 25 && today.windSpeedMax < 25) { + return { level: 'متوسط', color: 'text-green-600', description: 'تهویه طبیعی مناسب است' } + } + if (today.tempMin < 10) { + return { level: 'محدود', color: 'text-blue-600', description: 'تهویه را محدود کنید تا گرما حفظ شود' } + } + return { level: 'معمولی', color: 'text-gray-600', description: 'تهویه استاندارد کافی است' } +} + diff --git a/src/lib/api.ts b/src/lib/api.ts index 7bb7b4b..7407c5a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -79,6 +79,55 @@ export type PagedResult = { pageSize: number } +export type DailyReportDto = { + id: number + deviceId: number + deviceName: string + persianDate: string + analysis: string + recordCount: number + sampledRecordCount: number + totalTokens: number + createdAt: string + fromCache: boolean +} + +export type AlertRuleDto = { + id?: number + sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4 + comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3 + threshold: number + thresholdMax?: number // برای Between و OutOfRange +} + +export type AlertConditionDto = { + id: number + deviceId: number + notificationType: 0 | 1 // Call=0, SMS=1 + timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2 + isActive: boolean + rules: AlertRuleDto[] + createdAt: string + updatedAt: string +} + +export type CreateAlertConditionDto = { + deviceId: number + notificationType: 0 | 1 + timeType: 0 | 1 | 2 + isActive: boolean + rules: AlertRuleDto[] +} + +export type UpdateAlertConditionDto = { + id: number + deviceId: number + notificationType: 0 | 1 + timeType: 0 | 1 | 2 + isActive: boolean + rules: AlertRuleDto[] +} + const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir' async function http(url: string, init?: RequestInit): Promise { const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } }) @@ -140,5 +189,23 @@ export const api = { if (q.page) params.set('page', String(q.page)) if (q.pageSize) params.set('pageSize', String(q.pageSize)) return http>(`${API_BASE}/api/devices/filtered?${params.toString()}`) - } + }, + + // Daily Report + getDailyReport: (deviceId: number, persianDate: string) => + http(`${API_BASE}/api/DailyReport?deviceId=${deviceId}&persianDate=${encodeURIComponent(persianDate)}`), + + // Alert Conditions + getAlertConditions: (deviceId?: number) => { + const params = deviceId ? `?deviceId=${deviceId}` : '' + return http(`${API_BASE}/api/alertconditions${params}`) + }, + getAlertCondition: (id: number) => + http(`${API_BASE}/api/alertconditions/${id}`), + createAlertCondition: (dto: CreateAlertConditionDto) => + http(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }), + updateAlertCondition: (dto: UpdateAlertConditionDto) => + http(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }), + deleteAlertCondition: (id: number) => + http(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }) } diff --git a/src/lib/persian-date.ts b/src/lib/persian-date.ts index 0f09d93..0707ccb 100644 --- a/src/lib/persian-date.ts +++ b/src/lib/persian-date.ts @@ -70,3 +70,57 @@ export function getPersianTodayString(): string { const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته return `${persianWeekday} ${persian.day} ${months[persian.month - 1]} ${persian.year}` } + +/** + * Parse Persian date string in format "yyyy/MM/dd" and return PersianDate + */ +export function parsePersianDate(dateStr: string): PersianDate | null { + try { + const parts = dateStr.split('/') + if (parts.length !== 3) return null + const year = parseInt(parts[0]) + const month = parseInt(parts[1]) + const day = parseInt(parts[2]) + if (isNaN(year) || isNaN(month) || isNaN(day)) return null + return { year, month, day } + } catch { + return null + } +} + +/** + * Format Persian date as "yyyy/MM/dd" + */ +export function formatPersianDateString(date: PersianDate): string { + return `${date.year}/${String(date.month).padStart(2, '0')}/${String(date.day).padStart(2, '0')}` +} + +/** + * Get previous day in Persian calendar + */ +export function getPreviousPersianDay(dateStr: string): string | null { + const parsed = parsePersianDate(dateStr) + if (!parsed) return null + + // Convert to Gregorian, subtract one day, convert back to Persian + const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day) + gregorian.setDate(gregorian.getDate() - 1) + const prevPersian = gregorianToPersian(gregorian) + + return formatPersianDateString(prevPersian) +} + +/** + * Get next day in Persian calendar + */ +export function getNextPersianDay(dateStr: string): string | null { + const parsed = parsePersianDate(dateStr) + if (!parsed) return null + + // Convert to Gregorian, add one day, convert back to Persian + const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day) + gregorian.setDate(gregorian.getDate() + 1) + const nextPersian = gregorianToPersian(gregorian) + + return formatPersianDateString(nextPersian) +} \ No newline at end of file