diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 85534ae..a5149ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,6 +22,8 @@ jobs: with: runTests: false build: npm run build + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Save .next folder uses: actions/upload-artifact@v4 @@ -65,3 +67,4 @@ jobs: POSTGRES_URL: ${{ secrets.POSTGRES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/README.md b/README.md index 3e2d165..f8dfaad 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ SendTemps is a full-stack Next.js 14 application designed for Colorado Front Ran - **Seamless User Account Management:** Integrates Google OAuth and NextAuth for a secure, streamlined sign-in experience with persistent sessions, enabling easy access to location customization features. - **Interactive Mapping:** Features a point-and-click Google Maps interface for easy creation of backcountry locations, with an auto-updating, user-friendly UI. - **Robust Location Management:** Employs a Vercel PostgreSQL database for durable storage of custom locations, supporting full CRUD operations through Next.js APIs for dynamic user interaction. +- **AI-Powered Recommendations:** Using prompt engineering, integrated AI to analyze forecasts and recommend optimal days to engage in the sports that the user selected location is associated with. - **Optimized User Experience:** Implements intelligent error handling and automatic retry mechanisms for NOAA forecast API requests, ensuring smooth and informative user interactions. - **Standalone Display:** Designed for optimal performance on mobile devices, with capabilities for home screen addition, paving the way for full Progressive Web App functionality in the future. - **Hourly Forecast Display:** New as of 2.26.24 - Provides detailed hourly weather forecasts for more precise activity planning. @@ -26,7 +27,6 @@ SendTemps is a full-stack Next.js 14 application designed for Colorado Front Ran - **Multiple Sport Association:** Enable users to associate multiple sports with a single location for enhanced flexibility. - **Customizable Default Views:** Allow users to personalize the locations around the Front Range that are loaded by default. This would allow a user to further personalize the application, increasing engagement by reducing irrelevant information. - **Enhanced Search Functionality:** Implement text-based search with autocomplete to streamline location finding. This would be especially useful if a user has many locations and the current select inputs become too cumbersome. -- **AI-Powered Recommendations:** Using prompt engineering, integrate AI to analyze forecasts and recommend optimal days to engage in the sports that the location is associated with. ## Technical Challenges - Google OAuth / NextAuth @@ -45,6 +45,7 @@ SendTemps is a full-stack Next.js 14 application designed for Colorado Front Ran - Google OAuth - NextAuth - Google Maps API +- OpenAI API - Vercel Storage - Cypress E2E Testing diff --git a/cypress/e2e/dailyForecast.cy.ts b/cypress/e2e/dailyForecast.cy.ts index 24ec9d2..3331d92 100644 --- a/cypress/e2e/dailyForecast.cy.ts +++ b/cypress/e2e/dailyForecast.cy.ts @@ -26,6 +26,9 @@ describe("daily forecast display", () => { } ); + // Intercept OpenAI AI call + cy.intercept("/api/open_ai/send_score", { fixture: "sendscore.json" }); + cy.visit("/"); // Select Climbing in TypeSelect @@ -44,19 +47,26 @@ describe("daily forecast display", () => { .find("p.day-forecast-text") .should( "have.text", - "Sunny. High near 57, with temperatures falling to around 52 in the afternoon. West wind 30 to 36 mph, with gusts as high as 54 mph." + "Sunny. High near 57, with temperatures falling to around 52 in the afternoon. West wind 30 to 36 mph, with gusts as high as 54 mph. Humidity 17% to 19% RH." ); }); - it("should display the humidity details if available", () => { - cy.get("@todayForecast") - .find("div.day-header-details>p") - .eq(0) - .should("have.text", "Max 19% RH"); + it("should display a summary from AI data", () => { + cy.get("p.send-score-summary").should( + "have.text", + "Thursday is the best day for rock climbing with sunny skies, a high near 51°F, and light west winds. Friday is also a good option with a high near 61°F and light southwest winds. Saturday could work as well with a high near 59°F and mostly clear skies." + ); + }); - cy.get("@todayForecast") - .find("div.day-header-details>p") + it("should display the SendScore on forecast tiles", () => { + cy.get("article.detailed-day-forecast") .eq(1) - .should("have.text", "Min 17% RH"); + .as("tonightForecast") + .find("div.day-header-details>p") + .should("have.text", "SendScore: 1"); + }); + + it("should display a tip to click on forecast tiles for hourly forecasts", () => { + cy.get("p.hour-forecast-tip").should("be.visible"); }); }); diff --git a/cypress/e2e/forecastFetchErrors.cy.ts b/cypress/e2e/forecastFetchErrors.cy.ts index c5900ee..acda74b 100644 --- a/cypress/e2e/forecastFetchErrors.cy.ts +++ b/cypress/e2e/forecastFetchErrors.cy.ts @@ -61,6 +61,9 @@ describe("daily forecast display errors", () => { } ); + // Intercept OpenAI AI call + cy.intercept("/api/open_ai/send_score", { fixture: "sendscore.json" }); + cy.wait(10000); cy.get("div.loading-msg-div") @@ -95,6 +98,9 @@ describe("daily forecast display errors", () => { } ); + // Intercept OpenAI AI call + cy.intercept("/api/open_ai/send_score", { fixture: "sendscore.json" }); + cy.wait(10000); cy.get("div.loading-msg-div") @@ -104,4 +110,39 @@ describe("daily forecast display errors", () => { "Oh, no! All hourly forecast fetch attempts failed. Please reload the page and try again." ); }); + + it("should display an error when the OpenAI fetch fails", () => { + // Intercept Climbing - Lower Boulder Canyon fetchNoaaGridLocation call + cy.intercept("https://api.weather.gov/points/40.004482,-105.355800", { + fixture: "location_details.json", + }); + + // Intercept Climbing - Lower Boulder Canyon redirected fetchNoaaGridLocation call + cy.intercept("https://api.weather.gov/points/40.0045,-105.3558", { + fixture: "location_details.json", + }); + + // Intercept Lower Boulder Canyon Detailed daily forecast + cy.intercept("https://api.weather.gov/gridpoints/BOU/51,74/forecast", { + fixture: "detailed_forecast.json", + }); + + // Intercept Lower Boulder Canyon hourly forecast + cy.intercept( + "https://api.weather.gov/gridpoints/BOU/51,74/forecast/hourly", + { + fixture: "hourly_forecast.json", + } + ); + + // Intercept OpenAI AI call + cy.intercept("/api/open_ai/send_score", { statusCode: 500 }); + + cy.get("div.loading-msg-div") + .find("p.error-msg") + .should( + "have.text", + "Oh, no! An error occurred while creating SendScores." + ); + }); }); diff --git a/cypress/e2e/hourlyForecast.cy.ts b/cypress/e2e/hourlyForecast.cy.ts index 7aabaf4..d28ba08 100644 --- a/cypress/e2e/hourlyForecast.cy.ts +++ b/cypress/e2e/hourlyForecast.cy.ts @@ -26,6 +26,9 @@ describe("hourly forecast display", () => { } ); + // Intercept OpenAI AI call + cy.intercept("/api/open_ai/send_score", { fixture: "sendscore.json" }); + cy.visit("/"); // Select Climbing in TypeSelect diff --git a/cypress/fixtures/sendscore.json b/cypress/fixtures/sendscore.json new file mode 100644 index 0000000..9b40a65 --- /dev/null +++ b/cypress/fixtures/sendscore.json @@ -0,0 +1,61 @@ +{ + "summary": "Thursday is the best day for rock climbing with sunny skies, a high near 51°F, and light west winds. Friday is also a good option with a high near 61°F and light southwest winds. Saturday could work as well with a high near 59°F and mostly clear skies.", + "forecastPeriods": [ + { + "name": "This Afternoon", + "sendScore": 3 + }, + { + "name": "Tonight", + "sendScore": 1 + }, + { + "name": "Thursday", + "sendScore": 8 + }, + { + "name": "Thursday Night", + "sendScore": 2 + }, + { + "name": "Friday", + "sendScore": 7 + }, + { + "name": "Friday Night", + "sendScore": 3 + }, + { + "name": "Saturday", + "sendScore": 6 + }, + { + "name": "Saturday Night", + "sendScore": 2 + }, + { + "name": "Sunday", + "sendScore": 4 + }, + { + "name": "Sunday Night", + "sendScore": 1 + }, + { + "name": "Monday", + "sendScore": 3 + }, + { + "name": "Monday Night", + "sendScore": 1 + }, + { + "name": "Tuesday", + "sendScore": 3 + }, + { + "name": "Tuesday Night", + "sendScore": 1 + } + ] +} diff --git a/package-lock.json b/package-lock.json index f7e9858..7b2b49f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "lodash": "^4.17.21", "next": "^14.0.4", "next-auth": "^4.24.5", + "openai": "^4.31.0", "react": "^18", "react-dom": "^18" }, @@ -452,6 +453,28 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/pg": { "version": "8.6.6", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.6.tgz", @@ -646,6 +669,17 @@ "node": ">=14.6" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -667,6 +701,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -972,8 +1017,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -1035,6 +1079,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1227,6 +1276,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -1336,7 +1393,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -1396,6 +1452,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -1556,7 +1620,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -1570,6 +1633,15 @@ "node": ">=6" } }, + "node_modules/digest-fetch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", + "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "dependencies": { + "base-64": "^0.1.0", + "md5": "^2.3.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2182,6 +2254,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -2437,6 +2517,31 @@ "node": ">= 0.12" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -2799,6 +2904,14 @@ "node": ">=8.12.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2958,6 +3071,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -3623,6 +3741,16 @@ "node": ">=10" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3655,7 +3783,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -3664,7 +3791,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -3705,8 +3831,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -3804,6 +3929,43 @@ } } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.7.1.tgz", @@ -3989,6 +4151,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.31.0.tgz", + "integrity": "sha512-JebkRnRGEGLnJt3+bJ5B7au8nBeZvJjs9baVxDmUZ5+BgafAdy6KDxJGSuyaw/IA+ErqY3jmOH5cDC2mCDJF2w==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.28.tgz", + "integrity": "sha512-J5cOGD9n4x3YGgVuaND6khm5x07MMdAKkRyXnjVR6KFhLMNh2yONGiP7Z+4+tBOt5mK+GvDTiacTOVGGpqiecw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/openid-client": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", @@ -5059,6 +5248,11 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -5311,6 +5505,28 @@ "node": ">=10.13.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9962063..642022d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lodash": "^4.17.21", "next": "^14.0.4", "next-auth": "^4.24.5", + "openai": "^4.31.0", "react": "^18", "react-dom": "^18" }, diff --git a/public/serviceWorker.js b/public/serviceWorker.js deleted file mode 100644 index b9fc593..0000000 --- a/public/serviceWorker.js +++ /dev/null @@ -1,19 +0,0 @@ -self.addEventListener("install", () => { - // console.log("sw installed"); -}); - -self.addEventListener("activate", () => { - // console.log("sw activated"); -}); - -// Added to trigger browser add to home screen pop-up -// Changed to allow network requests in standalone -self.addEventListener('fetch', event => { - event.respondWith( - fetch(event.request) - .catch((err) => { - alert(`An error occurred with the network. - Please try your request again. ${err}`) - }) - ); -}); \ No newline at end of file diff --git a/src/app/Classes/OpenAIForecastData.ts b/src/app/Classes/OpenAIForecastData.ts new file mode 100644 index 0000000..5d592b3 --- /dev/null +++ b/src/app/Classes/OpenAIForecastData.ts @@ -0,0 +1,23 @@ +import { Forecast } from "./Forecast"; + +export class OpenAIForecastData { + sport: string; + forecastPeriods: { + name: string; + detailedForecast: string; + }[]; + constructor(sport: string, forecast: Forecast) { + this.sport = sport; + this.forecastPeriods = this.createForecastPeriods(forecast); + } + + createForecastPeriods(data: Forecast) { + const periods = data.periods.map((per) => { + return { + name: per.name, + detailedForecast: per.detailedForecast, + }; + }); + return periods; + } +} diff --git a/src/app/Components/DetailedDayForecast/DetailedDayForecast.tsx b/src/app/Components/DetailedDayForecast/DetailedDayForecast.tsx index 328f83c..99ac12f 100644 --- a/src/app/Components/DetailedDayForecast/DetailedDayForecast.tsx +++ b/src/app/Components/DetailedDayForecast/DetailedDayForecast.tsx @@ -7,21 +7,32 @@ interface Props { } const DetailedDayForecast: React.FC = ({ period }) => { - const { setHourlyForecastParams, hourlyForecastData } = - useContext(HomeContext); + const { + setHourlyForecastParams, + hourlyForecastData, + forecastSendScores, + } = useContext(HomeContext); + if (period && hourlyForecastData) { const hourlyParams = { name: period.name, start: period.startTime, end: period.endTime, }; + const minRH = hourlyForecastData.getMinRHForTimePeriod(hourlyParams); + + const sendScoreData = forecastSendScores?.forecastPeriods.find( + (score) => score.name === period.name + ); + return (
{ setHourlyForecastParams(hourlyParams); }} + title={`Click for ${period.name}'s hourly forecast`} >
{/* Using img here, had issues with loading using Image component */} @@ -38,15 +49,16 @@ const DetailedDayForecast: React.FC = ({ period }) => {

{period.name}

- {period.relativeHumidity ? ( - <> -

Max {period.relativeHumidity.value}% RH

-

Min {minRH.toLocaleString()}% RH

- - ) : null} + {sendScoreData?.sendScore && ( +

SendScore: {sendScoreData.sendScore}

+ )}
-

{period.detailedForecast}

+

{`${ + period.detailedForecast + } Humidity ${minRH.toLocaleString()}% to ${ + period.relativeHumidity.value + }% RH.`}

); } diff --git a/src/app/Components/HomeControl/HomeControl.tsx b/src/app/Components/HomeControl/HomeControl.tsx index ea5ae5a..1939df1 100644 --- a/src/app/Components/HomeControl/HomeControl.tsx +++ b/src/app/Components/HomeControl/HomeControl.tsx @@ -10,6 +10,8 @@ import { import { Gridpoint } from "@/app/Classes/Gridpoint"; import { Forecast } from "@/app/Classes/Forecast"; import { HourlyForecast } from "@/app/Classes/HourlyForecast"; +import { postForecastForSendScores } from "@/app/Util/OpenAiApiCalls"; +import { OpenAIForecastData } from "@/app/Classes/OpenAIForecastData"; export default function HomeControl() { const { @@ -23,6 +25,8 @@ export default function HomeControl() { forecastData, setForecastData, setHourlyForecastData, + forecastSendScores, + setForecastSendScores, setIsLoading, setError, } = useContext(HomeContext); @@ -103,6 +107,35 @@ export default function HomeControl() { forecastData, ]); + useEffect(() => { + // Fetch AI weather analysis + if (forecastData && !forecastSendScores && selectedLocType !== "other") { + const aiForecastData = new OpenAIForecastData( + selectedLocType, + forecastData + ); + const fetchAiWeatherAnalysis = async () => { + try { + const res = await postForecastForSendScores(aiForecastData); + if (res) { + setForecastSendScores(res); + } + } catch (error) { + console.error(error); + setError("An error occurred while creating SendScores."); + } + }; + fetchAiWeatherAnalysis(); + } + }, [ + forecastData, + locationDetails, + selectedLocType, + forecastSendScores, + setForecastSendScores, + setError, + ]); + // Ask for user location if Current Location selected useEffect(() => { if (selectedLocType === "Current Location") { diff --git a/src/app/Components/LocationSelect/LocationSelect.tsx b/src/app/Components/LocationSelect/LocationSelect.tsx index e0952c7..7d1a0a7 100644 --- a/src/app/Components/LocationSelect/LocationSelect.tsx +++ b/src/app/Components/LocationSelect/LocationSelect.tsx @@ -32,6 +32,7 @@ export default function LocationSelect() { setForecastData, setHourlyForecastData, setHourlyForecastParams, + setForecastSendScores, setIsLoading, setError, } = useContext(HomeContext); @@ -82,6 +83,7 @@ export default function LocationSelect() { setForecastData(undefined); setHourlyForecastData(undefined); setHourlyForecastParams(undefined); + setForecastSendScores(undefined); setSelectedLocCoords(e.target.value); // If coordinates for new selection, fetch location details diff --git a/src/app/Components/TypeSelect/TypeSelect.tsx b/src/app/Components/TypeSelect/TypeSelect.tsx index 9bc38d7..06aae4f 100644 --- a/src/app/Components/TypeSelect/TypeSelect.tsx +++ b/src/app/Components/TypeSelect/TypeSelect.tsx @@ -12,6 +12,7 @@ export default function TypeSelect() { setForecastData, setHourlyForecastData, setHourlyForecastParams, + setForecastSendScores, } = useContext(HomeContext); const { userInfo } = useContext(UserContext); @@ -23,6 +24,7 @@ export default function TypeSelect() { setForecastData(undefined); setHourlyForecastData(undefined); setHourlyForecastParams(undefined); + setForecastSendScores(undefined); setSelectedLocType(e.target.value); }; diff --git a/src/app/Contexts/HomeContext.tsx b/src/app/Contexts/HomeContext.tsx index ab3ca76..bd62b45 100644 --- a/src/app/Contexts/HomeContext.tsx +++ b/src/app/Contexts/HomeContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useState } from "react"; import { Coords, + ForecastSendScores, HourlyForecastParams, } from "../Interfaces/interfaces"; import { Gridpoint } from "../Classes/Gridpoint"; @@ -20,15 +21,19 @@ interface HomeContextType { React.SetStateAction >; forecastData: Forecast | undefined; - setForecastData: React.Dispatch< - React.SetStateAction - >; + setForecastData: React.Dispatch>; hourlyForecastData: HourlyForecast | undefined; setHourlyForecastData: React.Dispatch< React.SetStateAction >; hourlyForecastParams: HourlyForecastParams | undefined; - setHourlyForecastParams: React.Dispatch> + setHourlyForecastParams: React.Dispatch< + React.SetStateAction + >; + forecastSendScores: ForecastSendScores | undefined; + setForecastSendScores: React.Dispatch< + React.SetStateAction + >; screenWidth: number; setScreenWidth: React.Dispatch>; isLoading: boolean; @@ -54,6 +59,8 @@ export const HomeContext = createContext({ setHourlyForecastData: () => {}, hourlyForecastParams: undefined, setHourlyForecastParams: () => {}, + forecastSendScores: undefined, + setForecastSendScores: () => {}, screenWidth: 0, setScreenWidth: () => {}, isLoading: false, @@ -71,12 +78,15 @@ interface HomeProviderProps { export const HomeProvider: React.FC = ({ children }) => { const [currentGPSCoords, setCurrentGPSCoords] = useState(); const [selectedLocCoords, setSelectedLocCoords] = useState(""); - const [selectedLocType, setSelectedLocType] = - useState(""); + const [selectedLocType, setSelectedLocType] = useState(""); const [locationDetails, setLocationDetails] = useState(); const [forecastData, setForecastData] = useState(); - const [hourlyForecastData, setHourlyForecastData] = useState(); - const [hourlyForecastParams, setHourlyForecastParams] = useState(); + const [hourlyForecastData, setHourlyForecastData] = + useState(); + const [hourlyForecastParams, setHourlyForecastParams] = + useState(); + const [forecastSendScores, setForecastSendScores] = + useState(); const [screenWidth, setScreenWidth] = useState(0); const [isLoading, setIsLoading] = useState(false); const [pageLoaded, setPageLoaded] = useState(false); @@ -98,6 +108,8 @@ export const HomeProvider: React.FC = ({ children }) => { setHourlyForecastData, hourlyForecastParams, setHourlyForecastParams, + forecastSendScores, + setForecastSendScores, screenWidth, setScreenWidth, isLoading, diff --git a/src/app/Interfaces/interfaces.ts b/src/app/Interfaces/interfaces.ts index 5d9e80b..2c63f61 100644 --- a/src/app/Interfaces/interfaces.ts +++ b/src/app/Interfaces/interfaces.ts @@ -235,3 +235,8 @@ export interface GoogleMapPoint { lng: number; }; } + +export interface ForecastSendScores { + summary: string; + forecastPeriods: { name: string; sendScore: number }[]; +} diff --git a/src/app/Util/OpenAiApiCalls.ts b/src/app/Util/OpenAiApiCalls.ts new file mode 100644 index 0000000..85607fb --- /dev/null +++ b/src/app/Util/OpenAiApiCalls.ts @@ -0,0 +1,25 @@ +import { OpenAIForecastData } from "../Classes/OpenAIForecastData"; + +export async function postForecastForSendScores( + aiForecastData: OpenAIForecastData +) { + try { + const response = await fetch("/api/open_ai/send_score", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(aiForecastData), + credentials: "include", + }); + + if (response.ok) { + return await response.json(); + } else { + const errorData = await response.json(); + throw new Error(`Error response postNewUserLocation: ${errorData}`); + } + } catch (error) { + throw error; + } +} diff --git a/src/app/api/open_ai/send_score/route.ts b/src/app/api/open_ai/send_score/route.ts new file mode 100644 index 0000000..aa59458 --- /dev/null +++ b/src/app/api/open_ai/send_score/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import OpenAI from "openai"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +export async function POST(request: NextRequest) { + try { + const reqBody = await request.json(); + let sportString = ""; + let sportPrompt = ""; + + switch (reqBody.sport) { + case "climb": + sportString = "rock climbing"; + sportPrompt = + "Optimal rock climbing conditions are 50-75°F with sunny skies and and wind under 15mph. Wind gusts above 30mph are unfavorable and above 50mph should be avoided. Snow and rain are extremely undesirable, making it impossible to rock climb. Snowfall within a few days prior or heavy rain the day prior could leave the rock wet if not sunny and breezy."; + break; + case "mtb": + sportString = "mountain biking"; + sportPrompt = + "Optimal mountain biking conditions are 50-85°F with sunny skies and wind under 15mph. Below 50°F, sunny and calm conditions are preferable. Above 85°F, seek cloudy skies with a light breeze (up to 15mph). Adjust sendScore for wind speeds above 15mph, visibility issues, and trail conditions from recent heavy rain or snow."; + break; + case "ski": + sportString = "skiing"; + sportPrompt = + "Optimal skiing conditions are 15-45°F with sunny skies and wind speed under 20mph. New snowfall forecasted the night or day before, or on the day the user would engage in skiing is highly desirable. Snowfall amounts above 2 inches are most desireable and increases exponentially with higher snowfall forecasts. High wind and/or low wind chill values are the least desirable conditions for skiing, especially if it is also cloudy. Rain and freezing rain are very unfavorable conditions for skiing."; + } + + const aiPrompt = `Your task is to compute a "sendScore" between 1 and 10 for each forecast period and a text summary, reflecting the suitability for ${sportString}. Each day and night's forecast is represented by an object in the "forecastPeriods" array. All temperatures are in degrees Fahrenheit and winds in MPH. Night forecast periods are less desireable to participate in ${sportString} and should be scored significantly lower. Only return a JSON response with this structure: + { + "summary": "A brief summary indicating the best day, and also the next best options, for ${sportString} at the user's selected location based on the forecast periods. Do not reference sendScore values. It should be 1 to 3 sentences and does not need to explain that nights are less favorable.", + "forecastPeriods": [ + {"name": "The same name as each period", "sendScore": "A score representing the suitability of that period for ${sportString}"} + ... + ] + } + ${sportPrompt}`; + + const aiResponse = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: JSON.stringify(aiPrompt), + }, + { + role: "user", + content: JSON.stringify(reqBody.forecastPeriods), + }, + ], + temperature: 0.75, + max_tokens: 448, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + response_format: { type: "json_object" }, + }); + + if (aiResponse?.choices[0]?.message?.content) { + const content = JSON.parse(aiResponse.choices[0].message.content); + return NextResponse.json(content, { + status: 200, + }); + } else { + throw new Error("OpenAI response undefined"); + } + } catch (error) { + console.error(error); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/home.css b/src/app/home.css index 7fa4c53..c3242c3 100644 --- a/src/app/home.css +++ b/src/app/home.css @@ -306,13 +306,11 @@ select { } .day-header-details { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + min-width: 67px; font-weight: 600; - font-size: 0.8rem; + font-size: 1rem; position: relative; + text-align: right; } .home-welcome-header { @@ -342,7 +340,7 @@ select { font-size: 1.3rem; color: #f18f01; text-align: center; - padding: 1rem; + padding: 1rem 1rem 0rem 1rem; } .hour-forecast-tip { @@ -353,6 +351,13 @@ select { text-align: center; } +.send-score-summary { + font-size: 1.25rem; + color: #00b2ff; + margin: 0.5rem 0rem; + text-align: center; +} + /* Desktop */ @media screen and (min-width: 769px) { @@ -454,6 +459,10 @@ select { .forecast-section { max-width: 1500px; } + + .send-score-summary { + margin: 0.5rem 1rem; + } .day-forecast-container { display: grid; @@ -481,7 +490,8 @@ select { } .day-header-details { - font-size: 0.9rem; + min-width: 5.542rem; + font-size: 1.2rem; } .hourly-forecast-container { diff --git a/src/app/page.tsx b/src/app/page.tsx index d4fd66d..6785113 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,8 +12,10 @@ import HourlyForecastContainer from "./Components/HourlyForecastContainer/Hourly export default function Home() { const { + selectedLocType, forecastData, hourlyForecastParams, + forecastSendScores, screenWidth, setScreenWidth, isLoading, @@ -37,20 +39,24 @@ export default function Home() { }; }, [setPageLoaded]); - // Register service worker in production + // Unregister service worker in production, no longer used. + // Was causing issues with caching network requests useEffect(() => { - if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { + if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker - .register("/serviceWorker.js") - .then((registration) => { - console.log( - "Service Worker registered with scope:", - registration.scope - ); + .getRegistrations() + .then((registrations) => { + for (let registration of registrations) { + registration.unregister().then((res) => { + if (res === true) { + console.log("Service Worker unregistered successfully"); + } + }); + } }) .catch((error) => { - console.error("Service Worker registration failed:", error); + console.error("Service Worker unregistration failed:", error); }); }); } @@ -78,10 +84,10 @@ export default function Home() { } }, [isLoading]); - // Get sessionStorage item and set state indicating if user + // Get localStorage item and set state indicating if user // has seen the new hourly forecast feature useEffect(() => { - const hasSeenHourly = window.sessionStorage.getItem("hasSeenHourly"); + const hasSeenHourly = window.localStorage.getItem("hasSeenHourly"); if (!hasSeenHourly || hasSeenHourly === "false") { setHasSeenHourlyForecast(false); } else if (hasSeenHourly === "true") { @@ -93,7 +99,7 @@ export default function Home() { useEffect(() => { if (hourlyForecastParams && !hasSeenHourlyForecast) { setHasSeenHourlyForecast(true); - window.sessionStorage.setItem("hasSeenHourly", "true"); + window.localStorage.setItem("hasSeenHourly", "true"); } }, [hourlyForecastParams, hasSeenHourlyForecast]); @@ -130,7 +136,19 @@ export default function Home() { {hourlyForecastParams && } {forecastData && !hourlyForecastParams ? ( <> - {!hasSeenHourlyForecast && ( + {forecastSendScores?.summary ? ( +

+ {forecastSendScores?.summary} +

+ ) : ( + !error && + selectedLocType !== "other" && ( +

+ Loading SendScore™ analysis... +

+ ) + )} + {!hasSeenHourlyForecast && !error && (

Click on a day for an hourly forecast!