Repository: openclimatefix/quartz-frontend
Pull request #566: ✨ Quartz Solar v0.5.5 – Staging
Author: login: braddf
Created: 2025-04-07T15:53:32Z | Merged: 2025-04-28T10:01:56Z

# Pull Request

## Description

Included in this release are:
- 🔐 Non-auth dev mode (along with local Quartz Solar API update) #558 
- 📈 Fix for chart Y values of over 12GW (Credit: @suvanbanerjee) #561 
- 🌍 Fix for GSP data not loading and blank map in past due to API update #567 
- ⚡ Increase National capacity max constant for charts to 13.5GW #569 


Diff:
diff --git a/.gitignore b/.gitignore
index 59e2a66d..e217f745 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ node_modules
 build/**
 dist/**
 .next/**
+.env
\ No newline at end of file
diff --git a/apps/nowcasting-app/.env.example b/apps/nowcasting-app/.env.example
new file mode 100644
index 00000000..27a64fe5
--- /dev/null
+++ b/apps/nowcasting-app/.env.example
@@ -0,0 +1,4 @@
+#REACT_API_PREFIX_URL='http://localhost:8000/v0'
+#NEXT_PUBLIC_API_PREFIX='http://localhost:8000/v0'
+#NEXT_PUBLIC_4H_VIEW='true'
+#NEXT_PUBLIC_DEV_MODE='true'
diff --git a/apps/nowcasting-app/README.md b/apps/nowcasting-app/README.md
index 4b1cd93f..4f7b6ca2 100644
--- a/apps/nowcasting-app/README.md
+++ b/apps/nowcasting-app/README.md
@@ -1,7 +1,7 @@
 # Quartz Solar UI
 
 This is the main Quartz Solar UI, hosted at [app.quartz.solar](app.quartz.solar).
-It is used by customers to login and view their forecasts.
+It is used by customers to log in and view their forecasts.
 
 ## Getting Started
 
@@ -18,26 +18,15 @@ yarn install
 
 ### Setting Environment Variables
 
-The following environment variables need to be populated to make sure Auth0 is working properly.
-Place them in the file `.env.local` in the root of this app folder.
+The environment variables in `.env.example` need to be populated to make sure Auth0 is working properly.
+Place them in a `.env.local` file in the root of this app folder (`nowcasting-app`).
 
-```bash
-# ./apps/nowcasting-app/.env.local
-
-# A long, secret value used to encrypt the session cookie
-AUTH0_SECRET='<secret>'
-# The base url of your application
-AUTH0_BASE_URL='http://localhost:3002'
-# The url of your Auth0 tenant domain
-AUTH0_ISSUER_BASE_URL='https://nowcasting-dev.eu.auth0.com'
-# Your Auth0 application's Client ID
-AUTH0_CLIENT_ID='<client id>'
-# Your Auth0 application's Client Secret
-AUTH0_CLIENT_SECRET='<client secret>'
-# Disable Sentry on local to avoid noise 
-# (should default to off in development env anyway but just to make sure)
-NEXT_PUBLIC_SENTRY_DISABLED='true'
-```
+
+### Running the API locally
+
+The app needs to communicate with the API to fetch the forecasts, even if they are fake data in the local environment.
+To run the API locally, follow the instructions in the 
+[nowcasting-api README](https://github.com/openclimatefix/uk-pv-national-gsp-api) to set up and run the API.
 
 ### Running the App
 
@@ -52,10 +41,13 @@ Open [http://localhost:3002](http://localhost:3002) with your browser to see the
 
 ## Deployment
 
-The app gets automatically deployed to Vercel, on each push to the `main` branch.
+The app gets automatically deployed to Vercel, on each merge to the `development`, `staging`, and `main` branches.
+Any Pull Requests are deployed on their own respective Preview branches when granted an OCF Team member.
 
 ## Storybook
 
+This part of the app is slightly unloved, but it is still possible to run Storybook to see the components in isolation.
+
 ```bash
 yarn run storybook
 ```
diff --git a/apps/nowcasting-app/components/auth/CustomUserProvider.tsx b/apps/nowcasting-app/components/auth/CustomUserProvider.tsx
new file mode 100644
index 00000000..e8c8deb0
--- /dev/null
+++ b/apps/nowcasting-app/components/auth/CustomUserProvider.tsx
@@ -0,0 +1,16 @@
+import { UserProvider } from "@auth0/nextjs-auth0/client";
+
+const DevUserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+  const user = {
+    id: "123456",
+    email: "dev.user@email.com",
+    picture: "/favicon.ico",
+    username: "DevUser",
+    name: "Dev User",
+    locale: "en"
+  };
+  return <UserProvider user={user}>{children}</UserProvider>;
+};
+const CustomUserProvider =
+  process.env.NEXT_PUBLIC_DEV_MODE === "true" ? DevUserProvider : UserProvider;
+export default CustomUserProvider;
diff --git a/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx b/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx
index 2b3e565e..042ef702 100644
--- a/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx
+++ b/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx
@@ -1,10 +1,14 @@
 import { Dispatch, FC, SetStateAction, useEffect, useMemo } from "react";
 import RemixLine from "../remix-line";
-import { DELTA_BUCKET, MAX_NATIONAL_GENERATION_MW } from "../../../constant";
+import { DELTA_BUCKET, MAX_NATIONAL_GENERATION_MW, Y_MAX_TICKS } from "../../../constant";
 import ForecastHeader from "../forecast-header";
 import useGlobalState, { get30MinSlot } from "../../helpers/globalState";
 import useFormatChartData from "../use-format-chart-data";
-import { convertToLocaleDateString, formatISODateString } from "../../helpers/utils";
+import {
+  calculateChartYMax,
+  convertToLocaleDateString,
+  formatISODateString
+} from "../../helpers/utils";
 import GspPvRemixChart from "../gsp-pv-remix-chart";
 import { useStopAndResetTime } from "../../hooks/use-and-update-selected-time";
 import Spinner from "../../icons/spinner";
@@ -17,6 +21,7 @@ import DeltaBuckets from "./delta-buckets-ui";
 import useTimeNow from "../../hooks/use-time-now";
 import { ChartLegend } from "../ChartLegend";
 import DataLoadingChartStatus from "../DataLoadingChartStatus";
+import { getTicks } from "../../helpers/chartUtils";
 
 const GspDeltaColumn: FC<{
   gspDeltas: Map<string, GspDeltaValue> | undefined;
@@ -306,6 +311,10 @@ const DeltaChart: FC<DeltaChartProps> = ({ className, combinedData, combinedErro
     delta: true
   });
 
+  const yMax = useMemo(() => {
+    return calculateChartYMax(chartData, MAX_NATIONAL_GENERATION_MW);
+  }, [chartData]);
+
   // While N-hour is not available, we default to the latest interval with an Initial Estimate
   // useEffect(() => {
   //   if (selectedISOTime === get30MinNow() && view === VIEWS.DELTA) {
@@ -351,7 +360,8 @@ const DeltaChart: FC<DeltaChartProps> = ({ className, combinedData, combinedErro
               timeOfInterest={selectedTime}
               setTimeOfInterest={setSelectedTime}
               data={chartData}
-              yMax={MAX_NATIONAL_GENERATION_MW}
+              yMax={yMax}
+              yTicks={getTicks(yMax, Y_MAX_TICKS)}
               visibleLines={visibleLines}
               deltaView={true}
             />
diff --git a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx
index 2e75e858..28893078 100644
--- a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx
+++ b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx
@@ -14,20 +14,7 @@ import { ForecastValue } from "../../types";
 import React, { FC } from "react";
 import { NationalAggregation } from "../../map/types";
 import { getTicks } from "../../helpers/chartUtils";
-
-// Static constant below of this function so we don't call dynamically unnecessarily.
-// import { generateYMaxTickArray } from "../../helpers/chartUtils";
-// console.log("Y_MAX_TICKS", generateYMaxTickArray());
-//
-// We want to have the yMax of the graph to be related to the capacity of the GspPvRemixChart.
-// If we use the raw values, the graph looks funny, i.e y major ticks are 0 100 232
-// So, we round these up to the following numbers, which hopefully split nicely into the y-axis.
-// Uncomment the above function to get updated values should we need to change these
-const Y_MAX_TICKS = [
-  1, 2, 3, 4, 5, 6, 9, 10, 12, 15, 18, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 100, 150, 200, 250,
-  300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000,
-  6000, 7000, 8000, 9000, 10000, 12000, 14000, 15000, 16000, 18000, 20000
-];
+import { Y_MAX_TICKS } from "../../../constant";
 
 const GspPvRemixChart: FC<{
   gspId: number | string;
diff --git a/apps/nowcasting-app/components/charts/pv-remix-chart.tsx b/apps/nowcasting-app/components/charts/pv-remix-chart.tsx
index d8f19783..b9aad01d 100644
--- a/apps/nowcasting-app/components/charts/pv-remix-chart.tsx
+++ b/apps/nowcasting-app/components/charts/pv-remix-chart.tsx
@@ -7,11 +7,13 @@ import { formatISODateString } from "../helpers/utils";
 import GspPvRemixChart from "./gsp-pv-remix-chart";
 import { useStopAndResetTime } from "../hooks/use-and-update-selected-time";
 import Spinner from "../icons/spinner";
-import { MAX_NATIONAL_GENERATION_MW } from "../../constant";
+import { MAX_NATIONAL_GENERATION_MW, Y_MAX_TICKS } from "../../constant";
 import useHotKeyControlChart from "../hooks/use-hot-key-control-chart";
 import { CombinedData, CombinedErrors } from "../types";
 import { ChartLegend } from "./ChartLegend";
 import DataLoadingChartStatus from "./DataLoadingChartStatus";
+import { calculateChartYMax } from "../helpers/utils";
+import { getTicks } from "../helpers/chartUtils";
 
 const PvRemixChart: FC<{
   combinedData: CombinedData;
@@ -67,6 +69,10 @@ const PvRemixChart: FC<{
     timeTrigger: selectedTime
   });
 
+  const yMax = useMemo(() => {
+    return calculateChartYMax(chartData, MAX_NATIONAL_GENERATION_MW);
+  }, [chartData]);
+
   if (
     nationalForecastError ||
     pvRealDayInError ||
@@ -105,8 +111,9 @@ const PvRemixChart: FC<{
               timeOfInterest={selectedTime}
               setTimeOfInterest={setSelectedTime}
               data={chartData}
-              yMax={MAX_NATIONAL_GENERATION_MW}
+              yMax={yMax}
               visibleLines={visibleLines}
+              yTicks={getTicks(yMax, Y_MAX_TICKS)}
             />
           </div>
         </div>
diff --git a/apps/nowcasting-app/components/charts/remix-line.tsx b/apps/nowcasting-app/components/charts/remix-line.tsx
index 218430ef..e0a35b6c 100644
--- a/apps/nowcasting-app/components/charts/remix-line.tsx
+++ b/apps/nowcasting-app/components/charts/remix-line.tsx
@@ -14,11 +14,9 @@ import {
   YAxis
 } from "recharts";
 import {
-  convertISODateStringToLondonTime,
   convertToLocaleDateString,
   dateToLondonDateTimeString,
   formatISODateStringHumanNumbersOnly,
-  dateToLondonDateTimeOnlyString,
   getRoundedTickBoundary,
   prettyPrintChartAxisLabelDate,
   prettyPrintDayLabelWithDate
@@ -26,8 +24,6 @@ import {
 import { theme } from "../../tailwind.config";
 import useGlobalState, { getNext30MinSlot } from "../helpers/globalState";
 import { DELTA_BUCKET, VIEWS } from "../../constant";
-import get from "@auth0/nextjs-auth0/dist/auth0-session/client";
-import SVGComponent, { CloseButtonIcon } from "../icons/icons";
 import { getZoomYMax } from "../helpers/chartUtils";
 import { ZoomOutIcon } from "@heroicons/react/solid";
 
diff --git a/apps/nowcasting-app/components/helpers/chartUtils.test.ts b/apps/nowcasting-app/components/helpers/chartUtils.test.ts
new file mode 100644
index 00000000..250f8351
--- /dev/null
+++ b/apps/nowcasting-app/components/helpers/chartUtils.test.ts
@@ -0,0 +1,131 @@
+import { getTicks } from "./chartUtils";
+import { describe, expect, it } from "@jest/globals";
+
+describe("getTicks", () => {
+  it("should return ticks for a yMax divisible by 3", () => {
+    const ticks = getTicks(270, []);
+    expect(ticks).toEqual([90, 180, 270]);
+  });
+
+  it("should return ticks for a yMax divisible by 4", () => {
+    const ticks = getTicks(400, []);
+    expect(ticks).toEqual([100, 200, 300, 400]);
+  });
+
+  it("should return ticks for a yMax divisible by 5", () => {
+    const ticks = getTicks(500, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500]);
+  });
+
+  it("should return ticks for a yMax divisible by 6", () => {
+    const ticks = getTicks(600, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600]);
+  });
+
+  it("should return ticks for a yMax divisible by 7", () => {
+    const ticks = getTicks(700, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600, 700]);
+  });
+
+  it("should return the correct ticks for a yMax of 13000", () => {
+    const ticks = getTicks(13000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000]);
+  });
+
+  it("should return the correct ticks for a yMax of 14000", () => {
+    const ticks = getTicks(14000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000]);
+  });
+
+  it("should return the correct ticks for a yMax of 15000", () => {
+    const ticks = getTicks(15000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000, 15000]);
+  });
+
+  it("should return the correct ticks for a yMax of 50", () => {
+    const ticks = getTicks(50, []);
+    expect(ticks).toEqual([10, 20, 30, 40, 50]);
+  });
+
+  it("should return the correct ticks for a yMax of 100", () => {
+    const ticks = getTicks(100, []);
+    expect(ticks).toEqual([20, 40, 60, 80, 100]);
+  });
+
+  it("should return the correct ticks for a yMax of 150", () => {
+    const ticks = getTicks(150, []);
+    expect(ticks).toEqual([30, 60, 90, 120, 150]);
+  });
+
+  it("should return the correct ticks for a yMax of 200", () => {
+    const ticks = getTicks(200, []);
+    expect(ticks).toEqual([40, 80, 120, 160, 200]);
+  });
+
+  it("should return the correct ticks for a yMax of 250", () => {
+    const ticks = getTicks(250, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250]);
+  });
+
+  it("should return the correct ticks for a yMax of 300", () => {
+    const ticks = getTicks(300, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250, 300]);
+  });
+
+  it("should return the correct ticks for a yMax of 350", () => {
+    const ticks = getTicks(350, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250, 300, 350]);
+  });
+
+  it("should return the correct ticks for a yMax of 400", () => {
+    const ticks = getTicks(400, []);
+    expect(ticks).toEqual([100, 200, 300, 400]);
+  });
+
+  it("should return the correct ticks for a yMax of 450", () => {
+    const ticks = getTicks(450, []);
+    expect(ticks).toEqual([150, 300, 450]);
+  });
+
+  it("should return the correct ticks for a yMax of 500", () => {
+    const ticks = getTicks(500, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500]);
+  });
+
+  it("should return the correct ticks for a yMax of 600", () => {
+    const ticks = getTicks(600, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600]);
+  });
+
+  it("should return the correct ticks for a yMax of 700", () => {
+    const ticks = getTicks(700, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600, 700]);
+  });
+
+  it("should use fallback for non-round divisors", () => {
+    const ticks = getTicks(37, []);
+    // For yMax > 10, the fallback is 50
+    // But since 50 > 37, there's only one tick
+    expect(ticks).toEqual([]);
+  });
+
+  it("should use different fallbacks based on yMax size", () => {
+    const smallTicks = getTicks(3, []);
+    const mediumTicks = getTicks(15, []);
+    const largeTicks = getTicks(600, []);
+
+    expect(smallTicks.length).toBeGreaterThan(0);
+    expect(mediumTicks.length).toBeGreaterThan(0);
+    expect(largeTicks.length).toBeGreaterThan(0);
+  });
+
+  it("should handle decimal yMax values", () => {
+    const ticks = getTicks(2.5, []);
+    expect(ticks).toEqual([0.5, 1, 1.5, 2, 2.5]);
+  });
+
+  it("should handle zero and negative yMax", () => {
+    expect(getTicks(0, [])).toEqual([]);
+    expect(getTicks(-10, [])).toEqual([]);
+  });
+});
diff --git a/apps/nowcasting-app/components/helpers/chartUtils.ts b/apps/nowcasting-app/components/helpers/chartUtils.ts
index 4be91085..f9972fa8 100644
--- a/apps/nowcasting-app/components/helpers/chartUtils.ts
+++ b/apps/nowcasting-app/components/helpers/chartUtils.ts
@@ -47,7 +47,9 @@ export const generateYMaxTickArray = () => {
   // Multiples of 500
   yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 500)];
   // Multiples of 1000
-  yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 1000)];
+  yMax_levels = [...yMax_levels, ...Array.from({ length: 15 }, (_, i) => (i + 1) * 1000)];
+  // Multiples of 2500
+  yMax_levels = [...yMax_levels, ...Array.from({ length: 5 }, (_, i) => (i + 1) * 2500)];
   // Remove duplicates
   yMax_levels = [...new Set(yMax_levels)];
   // Sort
@@ -56,29 +58,41 @@ export const generateYMaxTickArray = () => {
 };
 
 export const getTicks = (yMax: number, yMax_levels: number[]) => {
+  if (yMax >= 13000 && yMax < 15000) {
+    return [3000, 6000, 9000, 12000];
+  }
   const ticks: number[] = [];
   const third = yMax / 3;
   const quarter = yMax / 4;
   const fifth = yMax / 5;
+  const sixth = yMax / 6;
   const seventh = yMax / 7;
   const testTicksToAdd = (fractionN: number) => {
-    let canSplit = true;
-    let tempTicks = [];
-    for (let i = fractionN; i <= yMax; i += fractionN) {
-      if (isRoundNumber(i) || i === yMax) {
-        tempTicks.push(i);
-      } else {
-        canSplit = false;
-        break;
+    if (!Number.isFinite(fractionN)) return;
+    if (fractionN > 0) {
+      let canSplit = true;
+      let tempTicks = [];
+      for (let i = fractionN; i <= yMax; i += fractionN) {
+        /*
+         TODO: this could check against the yMax_levels array / constant for strictly sticking
+         to the levels we want to show; but we need to be careful not to inhibit the default
+          behaviour of the chart library doing its job.
+        */
+        if (isRoundNumber(i) || i === yMax) {
+          tempTicks.push(i);
+        } else {
+          canSplit = false;
+          break;
+        }
+      }
+      if (canSplit) {
+        ticks.push(...tempTicks);
       }
-    }
-    if (canSplit) {
-      ticks.push(...tempTicks);
     }
   };
   const isRoundNumber = (n: number) => {
     if (n > 2000) {
-      return n % 500 === 0;
+      return n % 2500 === 0 || n % 1000 === 0;
     }
     if (n > 1000) {
       return n % 250 === 0 || n % 100 === 0;
@@ -94,20 +108,23 @@ export const getTicks = (yMax: number, yMax_levels: number[]) => {
     }
     return n % 0.5 === 0;
   };
-  if (isRoundNumber(third)) {
-    testTicksToAdd(third);
+  if (ticks.length === 0 && isRoundNumber(fifth)) {
+    testTicksToAdd(fifth);
+  }
+  if (ticks.length === 0 && isRoundNumber(sixth)) {
+    testTicksToAdd(sixth);
   }
   if (ticks.length === 0 && isRoundNumber(quarter)) {
     testTicksToAdd(quarter);
   }
-  if (ticks.length === 0 && isRoundNumber(fifth)) {
-    testTicksToAdd(fifth);
+  if (ticks.length === 0 && isRoundNumber(third)) {
+    testTicksToAdd(third);
   }
   if (ticks.length === 0 && isRoundNumber(seventh)) {
     testTicksToAdd(seventh);
   }
   if (ticks.length === 0) {
-    testTicksToAdd(yMax > 500 ? 100 : 50);
+    testTicksToAdd(yMax > 500 ? 100 : yMax > 10 ? 50 : 0.5);
   }
   return ticks;
 };
diff --git a/apps/nowcasting-app/components/helpers/data.test.ts b/apps/nowcasting-app/components/helpers/data.test.ts
index d86fc333..abd901c7 100644
--- a/apps/nowcasting-app/components/helpers/data.test.ts
+++ b/apps/nowcasting-app/components/helpers/data.test.ts
@@ -477,3 +477,75 @@ describe("check func returns correct timestamp", () => {
     expect(evenOlderTimestamp).toBe("2023-12-04T12:00:00+00:00");
   });
 });
+
+//////////////////////////////////
+// getEarliestForecastTimestamp //
+//////////////////////////////////
+import { getOldestTimestampFromForecastValues } from "./data";
+
+describe("getOldestTimestampFromForecastValues", () => {
+  test("should return the oldest timestamp from a valid forecast data array", () => {
+    const forecastValues: ForecastData = [
+      { targetTime: "2023-12-25T12:00:00Z", expectedPowerGenerationMegawatts: 1 },
+      { targetTime: "2023-12-24T12:00:00Z", expectedPowerGenerationMegawatts: 2 },
+      { targetTime: "2023-12-24T13:00:00Z", expectedPowerGenerationMegawatts: 2 },
+      { targetTime: "2023-12-26T12:00:00Z", expectedPowerGenerationMegawatts: 3 }
+    ];
+    const result = getOldestTimestampFromForecastValues(forecastValues);
+    expect(result).toBe("2023-12-24T12:00:00Z");
+  });
+
+  test("should return an empty string if the forecast data array is empty", () => {
+    const forecastValues: ForecastData = [];
+    const result = getOldestTimestampFromForecastValues(forecastValues);
+    expect(result).toBe("");
+  });
+
+  test("should still return the oldest timestamp even if incomplete string", () => {
+    const forecastValues = [
+      { targetTime: "2023-12-25T12:00", expectedPowerGenerationMegawatts: 1 }
+    ];
+    const result = getOldestTimestampFromForecastValues(forecastValues);
+    expect(result).toBe("2023-12-25T12:00");
+  });
+});
+
+//////////////////////////////////
+// getEarliestForecastTimestamp //
+//////////////////////////////////
+import { getEarliestForecastTimestamp } from "./data";
+import { ForecastData } from "../types";
+
+describe("getEarliestForecastTimestamp", () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  test("should return the correct timestamp when the current time is exactly on a 6-hour boundary", () => {
+    // Freeze time to a 6-hour boundary (e.g., 2023-12-07T12:00:00Z)
+    jest.setSystemTime(new Date("2023-12-07T12:00:00Z"));
+
+    const result = getEarliestForecastTimestamp();
+    expect(result).toBe("2023-12-05T12:00:00.000Z"); // Two days ago, rounded to 6-hour boundary
+  });
+
+  test("should return the correct timestamp when the current time is not on a 6-hour boundary", () => {
+    // Freeze time to a non-6-hour boundary (e.g., 2023-12-07T14:45:00Z)
+    jest.setSystemTime(new Date("2023-12-07T14:45:00Z"));
+
+    const result = getEarliestForecastTimestamp();
+    expect(result).toBe("2023-12-05T12:00:00.000Z"); // Two days ago, rounded to 6-hour boundary
+  });
+
+  test("should handle edge cases near midnight correctly", () => {
+    // Freeze time to just before midnight (e.g., 2023-12-07T23:59:59Z)
+    jest.setSystemTime(new Date("2023-12-07T23:59:59Z"));
+
+    const result = getEarliestForecastTimestamp();
+    expect(result).toBe("2023-12-05T18:00:00.000Z"); // Two days ago, rounded to 6-hour boundary
+  });
+});
diff --git a/apps/nowcasting-app/components/helpers/data.ts b/apps/nowcasting-app/components/helpers/data.ts
index 21e258c6..ebbc0972 100644
--- a/apps/nowcasting-app/components/helpers/data.ts
+++ b/apps/nowcasting-app/components/helpers/data.ts
@@ -1,5 +1,11 @@
 import { components } from "../../types/quartz-api";
-import { CombinedData, GspDeltaValue, GspZoneGroupings, MapFeatureObject } from "../types";
+import {
+  CombinedData,
+  ForecastData,
+  GspDeltaValue,
+  GspZoneGroupings,
+  MapFeatureObject
+} from "../types";
 import { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
 import gspShapeData from "../../data/gsp_regions_20220314.json";
 import dnoShapeData from "../../data/dno_regions_lat_long_converted.json";
@@ -400,6 +406,60 @@ export const getOldestTimestampFromCompactForecastValues = <
   );
 };
 
+/**
+ * Retrieves the oldest timestamp from a given `ForecastData` array.
+ *
+ * This function sorts the provided forecast data by the `targetTime` property in ascending order
+ * and returns the earliest (oldest) timestamp. If the array is empty or no valid `targetTime` is found,
+ * it defaults to returning an empty string.
+ *
+ * @param {ForecastData} forecastValues - An array of forecast data objects, each containing a `targetTime` property.
+ * @returns {string} The oldest timestamp as a string, or an empty string if no valid timestamp is found.
+ *
+ * @example
+ * const forecastValues = [
+ *   { targetTime: "2023-12-25T12:00:00Z", ... },
+ *   { targetTime: "2023-12-24T12:00:00Z", ... },
+ *   { targetTime: "2023-12-26T12:00:00Z", ... }
+ * ];
+ * console.log(getOldestTimestampFromForecastValues(forecastValues));
+ * // Output: "2023-12-24T12:00:00Z"
+ */
+export const getOldestTimestampFromForecastValues = (forecastValues: ForecastData): string => {
+  const sortedForecast = [...forecastValues].sort((a, b) => {
+    return a.targetTime > b.targetTime ? 1 : -1;
+  });
+  return sortedForecast?.[0]?.targetTime || "";
+};
+
+/**
+ * Calculates the earliest forecast timestamp based on the default behavior of the Quartz Solar API.
+ *
+ * This function determines the timestamp two days prior to the current time, rounded down to the nearest
+ * 6-hour boundary (e.g., 00:00, 06:00, 12:00, 18:00). The result is returned as an ISO 8601 string in UTC.
+ *
+ * @returns {string} The earliest forecast timestamp as an ISO 8601 string in UTC.
+ *
+ * @example
+ * // Assuming the current time is "2023-12-07T14:45:00Z":
+ * console.log(getEarliestForecastTimestamp());
+ * // Output: "2023-12-05T12:00:00.000Z"
+ */
+export const getEarliestForecastTimestamp = () => {
+  const now = new Date();
+  // Two days ago
+  const start = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
+  // floor to the nearest rounded 6 hours, e.g. 06:00, 12:00, 18:00, 00:00
+  const roundedDate = new Date(
+    Math.floor(start.getTime() / (6 * 60 * 60 * 1000)) * 6 * 60 * 60 * 1000
+  );
+  // Convert from local time to UTC
+  const utcOffset = roundedDate.getTimezoneOffset() * 60 * 1000;
+  roundedDate.setTime(roundedDate.getTime() + utcOffset);
+
+  return roundedDate.toISOString();
+};
+
 const MILLISECONDS_PER_MINUTE = 1000 * 60;
 
 /**
@@ -411,7 +471,7 @@ const MILLISECONDS_PER_MINUTE = 1000 * 60;
  * the number of milliseconds since the Unix Epoch (1970-01-01 00:00:00 UTC).
  * This is used to ensure an accurate and standardised measurement of time between the two dates provided.
  *
- * This function is used in conjunction with other datetime processing functions such as `calculateHistoricDataStartIntervalInMinutes`,
+ * This function is used in conjunction with other datetime processing functions such as `calculateHistoricDataStartFromCompactValuesIntervalInMinutes`,
  * `getOldestTimestampFromCompactForecastValues`, `get30MinNow`, and `getNext30MinSlot` to process and analyze interval data.
  *
  * @example
@@ -454,14 +514,14 @@ const calculateIntervalDuration = (dateOne: Date, dateTwo: Date): number => {
  *   { datetimeUtc: "2023-12-24T23:00:00" },
  *   { datetimeUtc: "2023-12-25T00:00:00" }
  * ];
- * console.log(calculateHistoricDataStartIntervalInMinutes(data));
+ * console.log(calculateHistoricDataStartFromCompactValuesIntervalInMinutes(data));
  * // Output: 30 (assuming current time is 2023-12-25T00:30:00)
  *
  * @param {T[]} data An array of objects `T` that includes a `datetimeUtc` field.
  *
  * @returns {number} The calculated interval duration in minutes, or `0` if no oldest timestamp is found.
  */
-export const calculateHistoricDataStartIntervalInMinutes = <
+export const calculateHistoricDataStartFromCompactValuesIntervalInMinutes = <
   T extends {
     datetimeUtc: string;
     generationKwByGspId?: any;
@@ -477,3 +537,14 @@ export const calculateHistoricDataStartIntervalInMinutes = <
   const comparisonDate = new Date(get30MinNow(-30));
   return calculateIntervalDuration(oldestDate, comparisonDate);
 };
+
+export const calculateHistoricDataStartFromForecastValuesIntervalInMinutes = (
+  forecastValues: ForecastData
+): number => {
+  const oldestTimestamp = getOldestTimestampFromForecastValues(forecastValues);
+  if (!oldestTimestamp) return 0;
+
+  const oldestDate = new Date(oldestTimestamp);
+  const comparisonDate = new Date(get30MinNow(-30));
+  return calculateIntervalDuration(oldestDate, comparisonDate);
+};
diff --git a/apps/nowcasting-app/components/helpers/utils.test.ts b/apps/nowcasting-app/components/helpers/utils.test.ts
index 14f36bba..f37cc0cf 100644
--- a/apps/nowcasting-app/components/helpers/utils.test.ts
+++ b/apps/nowcasting-app/components/helpers/utils.test.ts
@@ -5,6 +5,8 @@ import {
   prettyPrintChartAxisLabelDate
 } from "./utils";
 import * as utils from "./utils";
+import { MAX_NATIONAL_GENERATION_MW } from "../../constant";
+import { ChartData } from "../charts/remix-line";
 
 describe("check getOpacityValueFromPVNormalized with valid values", () => {
   test("check rounding to [0, 0.1, 0.2, 0.35, 0.5, 0.7, 1.0] and then selecting the correct opacity from [0.03, 0.2, 0.4, 0.6, 0.8, 1, 1]", () => {
@@ -181,3 +183,67 @@ describe("prettyPrintDayLabelWithDate", () => {
     expect(result).toBe("Today");
   });
 });
+
+describe("check y-axis max value calculation", () => {
+  test("should return 0 when chart data is empty array", () => {
+    const result = utils.calculateChartYMax([]);
+    expect(result).toBe(0);
+  });
+
+  test("should round up to nearest 500 with buffer of 100, and return MAX_NATIONAL if lower", () => {
+    const chartData: ChartData[] = [{ GENERATION: 10500, formattedDate: "2022-05-16T15:00" }];
+    const result = utils.calculateChartYMax(chartData);
+    expect(result).toBe(MAX_NATIONAL_GENERATION_MW); // 10500 + 100 = 10600 rounded to nearest 500 is 11000, which is less than MAX_NATIONAL_GENERATION_MW
+  });
+
+  test("should round up to next nearest 500 with buffer of 100", () => {
+    const chartData: ChartData[] = [{ GENERATION: 14450, formattedDate: "2022-05-16T15:00" }];
+    const result = utils.calculateChartYMax(chartData);
+    expect(result).toBe(15000); // 12500 + 1000 = 13500 rounded to nearest 1000 is 14000, which is more than MAX_NATIONAL_GENERATION_MW
+  });
+
+  test("should round up to nearest 500 with buffer of 100", () => {
+    const chartData: ChartData[] = [{ GENERATION: 14350, formattedDate: "2022-05-16T15:00" }];
+    const result = utils.calculateChartYMax(chartData);
+    expect(result).toBe(14500); // 14350 + 100 = 14450 rounded to nearest 500 is 14500, which is more than MAX_NATIONAL_GENERATION_MW
+  });
+
+  test("should handle multiple numeric values", () => {
+    const chartData: ChartData[] = [
+      { GENERATION: 5000, FORECAST: 8000, formattedDate: "2022-05-16T15:00" },
+      { GENERATION: 3000, FORECAST: 7000, formattedDate: "2022-05-16T16:00" }
+    ];
+    const result = utils.calculateChartYMax(chartData, 0); // the MAX_NATIONAL_GENERATION_MW is not considered for this test
+    expect(result).toBe(8500); // (8000 + 100) rounded up to nearest 500 is 8500
+  });
+
+  test("should ignore formattedDate fields", () => {
+    const chartData: ChartData[] = [{ GENERATION: 15500, formattedDate: "2023-10-12" }];
+    const result = utils.calculateChartYMax(chartData, 0); // the MAX_NATIONAL_GENERATION_MW is not considered for this test
+    expect(result).toBe(16000); // (15500 + 100) rounded up to nearest 500 is 16000
+  });
+
+  test("should handle edge case where value is just under rounding threshold", () => {
+    const chartData: ChartData[] = [{ GENERATION: 14990, formattedDate: "2022-05-16T15:00" }];
+    const result = utils.calculateChartYMax(chartData);
+    expect(result).toBe(15500); // (14990 + 100) rounded up to next 500
+  });
+
+  test("should handle edge case where values exactly match rounding threshold", () => {
+    const chartData: ChartData[] = [{ GENERATION: 15000, formattedDate: "2022-05-16T15:00" }];
+    const result = utils.calculateChartYMax(chartData);
+    expect(result).toBe(15500); // (15000 + 100) rounded up to next 500
+  });
+
+  test("should consider yMax if the value of yMax is greater than the Max National Generation", () => {
+    const chartData: ChartData[] = [{ GENERATION: 15000, formattedDate: "2022-05-16T15:00" }];
+    const result = Math.max(utils.calculateChartYMax(chartData), MAX_NATIONAL_GENERATION_MW);
+    expect(result).toBe(utils.calculateChartYMax(chartData));
+  });
+
+  test("should consider Max National Generation if the value of yMax is less than the Max National Generation", () => {
+    const chartData: ChartData[] = [{ GENERATION: 10000, formattedDate: "2022-05-16T15:00" }];
+    const result = Math.max(utils.calculateChartYMax(chartData), MAX_NATIONAL_GENERATION_MW);
+    expect(result).toBe(MAX_NATIONAL_GENERATION_MW);
+  });
+});
diff --git a/apps/nowcasting-app/components/helpers/utils.ts b/apps/nowcasting-app/components/helpers/utils.ts
index c4ff8a17..dcae23c5 100644
--- a/apps/nowcasting-app/components/helpers/utils.ts
+++ b/apps/nowcasting-app/components/helpers/utils.ts
@@ -1,5 +1,5 @@
 import axios from "axios";
-import { DELTA_BUCKET, getDeltaBucketKeys } from "../../constant";
+import { DELTA_BUCKET, getDeltaBucketKeys, MAX_NATIONAL_GENERATION_MW } from "../../constant";
 import {
   Bucket,
   CombinedData,
@@ -21,6 +21,7 @@ import * as Sentry from "@sentry/nextjs";
 import createClient from "openapi-fetch";
 import { paths } from "../../types/quartz-api";
 import { PathsWithMethod } from "openapi-typescript-helpers";
+import { ChartData } from "../charts/remix-line";
 
 export const isProduction = process.env.NEXT_PUBLIC_IS_PRODUCTION === "true";
 
@@ -394,7 +395,7 @@ export const axiosFetcherAuth = async (url: RequestInfo | URL) => {
       return res.data;
     })
     .catch((err) => {
-      if ([401, 403].includes(err.response.status)) {
+      if (process.env.NEXT_PUBLIC_DEV_MODE !== "true" && [401, 403].includes(err.response.status)) {
         Sentry.captureException(err, {
           tags: {
             error: "401/403 auth error"
@@ -573,3 +574,26 @@ export const createBucketObject: (
     increment
   };
 };
+export function calculateChartYMax(
+  chartData: ChartData[],
+  maxY: number = MAX_NATIONAL_GENERATION_MW
+): number {
+  if (!chartData || chartData.length === 0) {
+    // If there is no data, return 0
+    return 0;
+  }
+
+  const maxDataValue = Math.max(
+    0,
+    ...chartData.flatMap((dataPoint) =>
+      Object.entries(dataPoint)
+        .filter(([key, value]) => typeof value === "number" && key !== "formattedDate")
+        .map(([_, value]) => value as number)
+    )
+  );
+
+  const valueWithBuffer = maxDataValue + 100;
+  const roundingFactor = 500;
+
+  return Math.max(Math.ceil(valueWithBuffer / roundingFactor) * roundingFactor, maxY);
+}
diff --git a/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx b/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx
index 23a7e0cb..aa569b08 100644
--- a/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx
+++ b/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx
@@ -1,12 +1,11 @@
 import React, { Fragment, useEffect } from "react";
 import { Menu, Transition } from "@headlessui/react";
-import { useUser } from "@auth0/nextjs-auth0";
+import { useUser } from "@auth0/nextjs-auth0/client";
 import pkg from "../../../package.json";
 import { classNames, formatISODateStringHumanNumbersOnly, isProduction } from "../../helpers/utils";
 import Link from "next/link";
 import Tooltip from "../../tooltip";
 import useGlobalState from "../../helpers/globalState";
-import { ChartInfo } from "../../../ChartInfo";
 import { Checkmark, ExternalLinkIcon } from "../../icons/icons";
 import {
   CookieStorageKeys,
diff --git a/apps/nowcasting-app/constant.ts b/apps/nowcasting-app/constant.ts
index 647dd8da..03a3cf4c 100644
--- a/apps/nowcasting-app/constant.ts
+++ b/apps/nowcasting-app/constant.ts
@@ -3,7 +3,21 @@ export const API_PREFIX = process.env.NEXT_PUBLIC_API_PREFIX || "https://api-dev
 export const SITES_API_PREFIX =
   process.env.NEXT_PUBLIC_SITES_API_PREFIX || "https://api-site-dev.quartz.solar";
 export const MAX_POWER_GENERATED = 500;
-export const MAX_NATIONAL_GENERATION_MW = 12000;
+export const MAX_NATIONAL_GENERATION_MW = 13500;
+
+// Static constant below of this function so we don't call dynamically unnecessarily.
+// import { generateYMaxTickArray } from "./components/helpers/chartUtils";
+// console.log("Y_MAX_TICKS", generateYMaxTickArray());
+//
+// We want to have the yMax of the graph to be related to the capacity of the GspPvRemixChart.
+// If we use the raw values, the graph looks funny, i.e y major ticks are 0 100 232
+// So, we round these up to the following numbers, which hopefully split nicely into the y-axis.
+// Uncomment the above function to get updated values should we need to change these
+export const Y_MAX_TICKS = [
+  1, 2, 3, 4, 5, 6, 9, 10, 12, 15, 18, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 100, 150, 200, 250,
+  300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000,
+  6000, 7000, 7500, 8000, 9000, 10000, 11000, 12000, 12500, 13000, 14000, 15000
+];
 
 export const getAllForecastUrl = (isNormalized: boolean, isHistoric: boolean) =>
   `${API_PREFIX}/solar/GB/gsp/forecast/all/?UI&${isHistoric ? "historic=true" : ""}${
diff --git a/apps/nowcasting-app/next.config.js b/apps/nowcasting-app/next.config.js
index 40a4cdb6..26788609 100644
--- a/apps/nowcasting-app/next.config.js
+++ b/apps/nowcasting-app/next.config.js
@@ -18,6 +18,7 @@ const sentryWebpackPluginOptions = {
 
   org: "openclimatefix",
   project: "quartz-solar-app",
+  enabled: process.env.NEXT_PUBLIC_DEV_MODE !== "true",
 
   silent: true // Suppresses all logs
   // For all available options, see:
diff --git a/apps/nowcasting-app/package.json b/apps/nowcasting-app/package.json
index e0cf5d9c..2083c191 100644
--- a/apps/nowcasting-app/package.json
+++ b/apps/nowcasting-app/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@openclimatefix/nowcasting-app",
-  "version": "0.5.4",
+  "version": "0.5.5",
   "private": true,
   "scripts": {
     "dev": "next dev -p 3002",
@@ -14,7 +14,7 @@
     "test:cypress": "cypress run --browser chrome"
   },
   "dependencies": {
-    "@auth0/nextjs-auth0": "^1.8.0",
+    "@auth0/nextjs-auth0": "^3.5.0",
     "@headlessui/react": "^1.6.1",
     "@heroicons/react": "^1.0.6",
     "@next/third-parties": "^14.2.5",
diff --git a/apps/nowcasting-app/pages/_app.tsx b/apps/nowcasting-app/pages/_app.tsx
index 330b2aa6..ff8fcb28 100644
--- a/apps/nowcasting-app/pages/_app.tsx
+++ b/apps/nowcasting-app/pages/_app.tsx
@@ -1,5 +1,3 @@
-import type { AppProps } from "next/app";
-import { UserProvider } from "@auth0/nextjs-auth0";
 import { ToastContainer, toast } from "react-toastify";
 import "../styles/globals.css";
 import { SWRConfig } from "swr";
@@ -7,10 +5,11 @@ import { apiErrorMSGS } from "../constant";
 import * as Sentry from "@sentry/nextjs";
 import { AxiosError } from "axios";
 import { GoogleTagManager } from "@next/third-parties/google";
+import CustomUserProvider from "../components/auth/CustomUserProvider";
 
 function MyApp({ Component, pageProps }: any) {
   return (
-    <UserProvider>
+    <CustomUserProvider>
       <SWRConfig
         value={{
           provider: () => new Map(),
@@ -39,7 +38,7 @@ function MyApp({ Component, pageProps }: any) {
         <Component {...pageProps} />
         <GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID || ""} />
       </SWRConfig>
-    </UserProvider>
+    </CustomUserProvider>
   );
 }
 
diff --git a/apps/nowcasting-app/pages/api/get_token.ts b/apps/nowcasting-app/pages/api/get_token.ts
index eea2b767..9eba2174 100644
--- a/apps/nowcasting-app/pages/api/get_token.ts
+++ b/apps/nowcasting-app/pages/api/get_token.ts
@@ -1,10 +1,26 @@
 import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
+import { NextApiRequest, NextApiResponse } from "next";
 
-export default withApiAuthRequired(async function token(req, res) {
-  try {
-    const accessToken = await getAccessToken(req, res);
-    res.status(200).json(accessToken);
-  } catch (error: any) {
-    res.status(error.status || 400).end(error.message);
-  }
-});
+export default process.env.NEXT_PUBLIC_DEV_MODE === "true"
+  ? async function token(req: NextApiRequest, res: NextApiResponse) {
+      if (process.env.NEXT_PUBLIC_DEV_MODE === "true") {
+        return res.status(200).json({ accessToken: "FAKE_TOKEN" });
+      }
+      try {
+        const accessToken = await getAccessToken(req, res);
+        res.status(200).json(accessToken);
+      } catch (error: any) {
+        res.status(error.status || 400).end(error.message);
+      }
+    }
+  : withApiAuthRequired(async function token(req: NextApiRequest, res: NextApiResponse) {
+      if (process.env.NEXT_PUBLIC_DEV_MODE === "true") {
+        return res.status(200).json({ accessToken: "FAKE_TOKEN" });
+      }
+      try {
+        const accessToken = await getAccessToken(req, res);
+        res.status(200).json(accessToken);
+      } catch (error: any) {
+        res.status(error.status || 400).end(error.message);
+      }
+    });
diff --git a/apps/nowcasting-app/pages/index.tsx b/apps/nowcasting-app/pages/index.tsx
index 82d52786..7cf36ab0 100644
--- a/apps/nowcasting-app/pages/index.tsx
+++ b/apps/nowcasting-app/pages/index.tsx
@@ -1,4 +1,5 @@
-import { useUser, withPageAuthRequired } from "@auth0/nextjs-auth0";
+import { useUser } from "@auth0/nextjs-auth0/client";
+import { withPageAuthRequired } from "@auth0/nextjs-auth0";
 import Layout from "../components/layout/layout";
 import { PvLatestMap } from "../components/map";
 import SideLayout from "../components/side-layout";
@@ -49,8 +50,11 @@ import { useLoadDataFromApi } from "../components/hooks/useLoadDataFromApi";
 import {
   filterCompactFutureData,
   filterCompactHistoricData,
-  calculateHistoricDataStartIntervalInMinutes,
-  getOldestTimestampFromCompactForecastValues
+  calculateHistoricDataStartFromCompactValuesIntervalInMinutes,
+  getOldestTimestampFromCompactForecastValues,
+  getOldestTimestampFromForecastValues,
+  calculateHistoricDataStartFromForecastValuesIntervalInMinutes,
+  getEarliestForecastTimestamp
 } from "../components/helpers/data";
 
 export default function Home({ dashboardModeServer }: { dashboardModeServer: string }) {
@@ -165,7 +169,20 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str
     isValidating: nationalForecastValidating,
     error: nationalForecastError
   } = useLoadDataFromApi<ForecastData>(
-    `${API_PREFIX}/solar/GB/national/forecast?historic=false&only_forecast_values=true`
+    `${API_PREFIX}/solar/GB/national/forecast?historic=false&only_forecast_values=true`,
+    {
+      keepPreviousData: true,
+      refreshInterval: 0, // Only load this once at beginning
+      onSuccess: (data) => {
+        if (!data) return;
+
+        const historicBackwardIntervalMinutes =
+          calculateHistoricDataStartFromForecastValuesIntervalInMinutes(data);
+        const prev30MinNowISO = `${get30MinNow(-30)}:00+00:00`;
+        setForecastLastFetch30MinISO(prev30MinNowISO);
+        setForecastHistoricBackwardIntervalMinutes(historicBackwardIntervalMinutes);
+      }
+    }
   );
 
   const {
@@ -236,15 +253,16 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str
     }
   );
   // Load historic all GSP forecast data (once, at page load)
+  const forecastFrom = getEarliestForecastTimestamp();
   const {
     data: allGspForecastHistoricData,
     isLoading: allGspForecastHistoricLoading,
     isValidating: allGspForecastHistoricValidating,
     error: allGspForecastHistoricError
   } = useLoadDataFromApi<components["schemas"]["OneDatetimeManyForecastValues"][]>(
-    `${API_PREFIX}/solar/GB/gsp/forecast/all/?historic=true&compact=true&end_datetime_utc=${encodeURIComponent(
-      `${forecastLastFetch30MinISO.slice(0, 19)}+00:00`
-    )}`,
+    `${API_PREFIX}/solar/GB/gsp/forecast/all/?historic=true&compact=true&start_datetime_utc=${encodeURIComponent(
+      `${forecastFrom.slice(0, 19)}+00:00`
+    )}&end_datetime_utc=${encodeURIComponent(`${forecastLastFetch30MinISO.slice(0, 19)}+00:00`)}`,
     {
       keepPreviousData: true,
       refreshInterval: 0, // Only load this once at beginning
@@ -252,10 +270,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str
         if (!data) return;
 
         const oldestTimestamp = getOldestTimestampFromCompactForecastValues(data);
-        const historicBackwardIntervalMinutes = calculateHistoricDataStartIntervalInMinutes(data);
         const prev30MinNowISO = `${get30MinNow(-30)}:00+00:00`;
-        setForecastLastFetch30MinISO(prev30MinNowISO);
-        setForecastHistoricBackwardIntervalMinutes(historicBackwardIntervalMinutes);
         setAllGspForecastHistory(filterCompactHistoricData(data, oldestTimestamp, prev30MinNowISO));
       }
     }
@@ -342,7 +357,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str
             components["schemas"]["GSPYieldGroupByDatetime"]
           >(data);
         const historicBackwardIntervalMinutes =
-          calculateHistoricDataStartIntervalInMinutes<
+          calculateHistoricDataStartFromCompactValuesIntervalInMinutes<
             components["schemas"]["GSPYieldGroupByDatetime"]
           >(data);
         const prev30MinNowISO = `${get30MinNow(-30)}:00+00:00`;
@@ -719,13 +734,23 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str
   );
 }
 
-export const getServerSideProps = withPageAuthRequired({
-  async getServerSideProps(context) {
-    const cookies = new Cookies(context.req, context.res);
-    return {
-      props: {
-        dashboardModeServer: cookies.get(CookieStorageKeys.DASHBOARD_MODE) || false
+export const getServerSideProps =
+  process.env.NEXT_PUBLIC_DEV_MODE === "true"
+    ? (context: any) => {
+        const cookies = new Cookies(context.req, context.res);
+        return {
+          props: {
+            dashboardModeServer: cookies.get(CookieStorageKeys.DASHBOARD_MODE) || false
+          }
+        };
       }
-    };
-  }
-});
+    : withPageAuthRequired({
+        async getServerSideProps(context) {
+          const cookies = new Cookies(context.req, context.res);
+          return {
+            props: {
+              dashboardModeServer: cookies.get(CookieStorageKeys.DASHBOARD_MODE) || false
+            }
+          };
+        }
+      });
diff --git a/yarn.lock b/yarn.lock
index e1fde69b..cf3aa9bd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -40,20 +40,18 @@
     tslib "^2.4.0"
     url-join "^4.0.1"
 
-"@auth0/nextjs-auth0@^1.8.0":
-  version "1.9.3"
-  resolved "https://registry.yarnpkg.com/@auth0/nextjs-auth0/-/nextjs-auth0-1.9.3.tgz#b42ce057e34eb12c3c1c6f53964b8888367a20f6"
-  integrity sha512-J+xzP6jh+F4b7JrPYeYjesY9Kdihj9gcEreYQ55qZeB43p0VMSZJSO5EqduDmgn585tKbt1TZccO7xaX0YbEeA==
+"@auth0/nextjs-auth0@^3.5.0":
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/@auth0/nextjs-auth0/-/nextjs-auth0-3.5.0.tgz#f596d641cba7a96b924481fda384e7feb811bb26"
+  integrity sha512-uFZEE2QQf1zU+jRK2fwqxRQt+WSqDPYF2tnr7d6BEa7b6L6tpPJ3evzoImbWSY1a7gFdvD7RD/Rvrsx7B5CKVg==
   dependencies:
-    base64url "^3.0.1"
-    cookie "^0.5.0"
+    "@panva/hkdf" "^1.0.2"
+    cookie "^0.6.0"
     debug "^4.3.4"
-    futoin-hkdf "^1.5.0"
-    http-errors "^1.8.1"
     joi "^17.6.0"
-    jose "^2.0.5"
-    on-headers "^1.0.2"
-    openid-client "^4.9.1"
+    jose "^4.9.2"
+    oauth4webapi "^2.3.0"
+    openid-client "^5.2.1"
     tslib "^2.4.0"
     url-join "^4.0.1"
 
@@ -2475,11 +2473,6 @@
   dependencies:
     "@opentelemetry/core" "^1.1.0"
 
-"@panva/asn1.js@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6"
-  integrity sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==
-
 "@panva/hkdf@^1.0.2":
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.1.1.tgz#ab9cd8755d1976e72fc77a00f7655a64efe6cd5d"
@@ -2781,11 +2774,6 @@
   resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
   integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
 
-"@sindresorhus/is@^4.0.0":
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
-  integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==
-
 "@sinonjs/commons@^3.0.0":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
@@ -3631,13 +3619,6 @@
   dependencies:
     tslib "^2.4.0"
 
-"@szmarczak/http-timer@^4.0.5":
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807"
-  integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==
-  dependencies:
-    defer-to-connect "^2.0.0"
-
 "@tailwindcss/container-queries@^0.1.1":
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz#9a759ce2cb8736a4c6a0cb93aeb740573a731974"
@@ -5234,16 +5215,6 @@
     "@types/connect" "*"
     "@types/node" "*"
 
-"@types/cacheable-request@^6.0.1":
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz#a430b3260466ca7b5ca5bfd735693b36e7a9d183"
-  integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==
-  dependencies:
-    "@types/http-cache-semantics" "*"
-    "@types/keyv" "^3.1.4"
-    "@types/node" "*"
-    "@types/responselike" "^1.0.0"
-
 "@types/connect@*":
   version "3.4.38"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
@@ -5424,11 +5395,6 @@
   resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
   integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==
 
-"@types/http-cache-semantics@*":
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4"
-  integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==
-
 "@types/http-errors@*":
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f"
@@ -5502,13 +5468,6 @@
   resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.6.tgz#1749535181a2a9b02ac04a797550a8787345b740"
   integrity sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==
 
-"@types/keyv@^3.1.4":
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"
-  integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==
-  dependencies:
-    "@types/node" "*"
-
 "@types/leaflet@^1.7.9":
   version "1.9.11"
   resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.11.tgz#91c489f098db821204a401a9f49b9a6aed0e0b17"
@@ -5672,13 +5631,6 @@
     "@types/prop-types" "*"
     csstype "^3.0.2"
 
-"@types/responselike@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50"
-  integrity sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==
-  dependencies:
-    "@types/node" "*"
-
 "@types/semver@^7.3.4":
   version "7.5.8"
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
@@ -6091,7 +6043,7 @@ agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1:
   dependencies:
     debug "^4.3.4"
 
-aggregate-error@^3.0.0, aggregate-error@^3.1.0:
+aggregate-error@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
   integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==
@@ -6626,11 +6578,6 @@ base64-js@^1.3.1:
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
   integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
-base64url@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
-  integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
-
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
@@ -6790,24 +6737,6 @@ cacache@^18.0.0:
     tar "^6.1.11"
     unique-filename "^3.0.0"
 
-cacheable-lookup@^5.0.3:
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005"
-  integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==
-
-cacheable-request@^7.0.2:
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.4.tgz#7a33ebf08613178b403635be7b899d3e69bbe817"
-  integrity sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==
-  dependencies:
-    clone-response "^1.0.2"
-    get-stream "^5.1.0"
-    http-cache-semantics "^4.0.0"
-    keyv "^4.0.0"
-    lowercase-keys "^2.0.0"
-    normalize-url "^6.0.1"
-    responselike "^2.0.0"
-
 cachedir@^2.3.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.4.0.tgz#7fef9cf7367233d7c88068fe6e34ed0d355a610d"
@@ -7039,13 +6968,6 @@ clone-deep@^4.0.1:
     kind-of "^6.0.2"
     shallow-clone "^3.0.0"
 
-clone-response@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3"
-  integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==
-  dependencies:
-    mimic-response "^1.0.0"
-
 clsx@^1.1.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
@@ -7194,7 +7116,7 @@ cookie-signature@1.0.6:
   resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
   integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
 
-cookie@0.6.0:
+cookie@0.6.0, cookie@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
   integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
@@ -7590,13 +7512,6 @@ decimal.js@^10.4.2:
   resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
   integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
 
-decompress-response@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
-  integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
-  dependencies:
-    mimic-response "^3.1.0"
-
 dedent@^1.0.0:
   version "1.5.3"
   resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a"
@@ -7636,11 +7551,6 @@ deepmerge@^4.2.2:
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-defer-to-connect@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
-  integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
-
 define-data-property@^1.0.1, define-data-property@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
@@ -7688,11 +7598,6 @@ depd@2.0.0, depd@~2.0.0:
   resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
   integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
 
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
-
 dequal@^2.0.2, dequal@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
@@ -9071,11 +8976,6 @@ functions-have-names@^1.2.3:
   resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
   integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
 
-futoin-hkdf@^1.5.0:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz#6c8024f2e1429da086d4e18289ef2239ad33ee35"
-  integrity sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==
-
 gauge@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
@@ -9326,23 +9226,6 @@ gopd@^1.0.1:
   dependencies:
     get-intrinsic "^1.1.3"
 
-got@^11.8.0:
-  version "11.8.6"
-  resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
-  integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==
-  dependencies:
-    "@sindresorhus/is" "^4.0.0"
-    "@szmarczak/http-timer" "^4.0.5"
-    "@types/cacheable-request" "^6.0.1"
-    "@types/responselike" "^1.0.0"
-    cacheable-lookup "^5.0.3"
-    cacheable-request "^7.0.2"
-    decompress-response "^6.0.0"
-    http2-wrapper "^1.0.0-beta.5.2"
-    lowercase-keys "^2.0.0"
-    p-cancelable "^2.0.0"
-    responselike "^2.0.0"
-
 graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
   version "4.2.11"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -9515,7 +9398,7 @@ htmlparser2@^6.1.0:
     domutils "^2.5.2"
     entities "^2.0.0"
 
-http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.1:
+http-cache-semantics@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
   integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
@@ -9531,17 +9414,6 @@ http-errors@2.0.0:
     statuses "2.0.1"
     toidentifier "1.0.1"
 
-http-errors@^1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
-  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.4"
-    setprototypeof "1.2.0"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.1"
-
 http-proxy-agent@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@@ -9568,14 +9440,6 @@ http-signature@~1.3.6:
     jsprim "^2.0.2"
     sshpk "^1.14.1"
 
-http2-wrapper@^1.0.0-beta.5.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
-  integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==
-  dependencies:
-    quick-lru "^5.1.1"
-    resolve-alpn "^1.0.0"
-
 https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
@@ -10550,13 +10414,6 @@ joi@^17.6.0:
     "@sideway/formula" "^3.0.1"
     "@sideway/pinpoint" "^2.0.0"
 
-jose@^2.0.5:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.7.tgz#3aabbaec70bff313c108b9406498a163737b16ba"
-  integrity sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==
-  dependencies:
-    "@panva/asn1.js" "^1.0.0"
-
 jose@^4.15.5, jose@^4.9.2:
   version "4.15.5"
   resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706"
@@ -10742,7 +10599,7 @@ keygrip@~1.1.0:
   dependencies:
     tsscmp "1.0.6"
 
-keyv@^4.0.0, keyv@^4.5.3:
+keyv@^4.5.3:
   version "4.5.4"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
   integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
@@ -11038,11 +10895,6 @@ lower-case@^2.0.2:
   dependencies:
     tslib "^2.0.3"
 
-lowercase-keys@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
-  integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
-
 lru-cache@^10.0.1, lru-cache@^10.2.0:
   version "10.2.0"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3"
@@ -11115,7 +10967,7 @@ make-dir@^4.0.0:
   dependencies:
     semver "^7.5.3"
 
-make-error@1.x, make-error@^1.1.1, make-error@^1.3.6:
+make-error@1.x, make-error@^1.1.1:
   version "1.3.6"
   resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
   integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
@@ -11267,16 +11119,6 @@ mimic-fn@^4.0.0:
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
   integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
 
-mimic-response@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
-  integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
-
-mimic-response@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
-  integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
-
 min-document@^2.19.0:
   version "2.19.0"
   resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
@@ -11556,11 +11398,6 @@ normalize-range@^0.1.2:
   resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
   integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
 
-normalize-url@^6.0.1:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
-  integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
-
 npm-run-path@^4.0.0, npm-run-path@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@@ -11607,7 +11444,7 @@ object-assign@^4.0.1, object-assign@^4.1.1:
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
-object-hash@^2.0.1, object-hash@^2.2.0:
+object-hash@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
   integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
@@ -11696,7 +11533,7 @@ obuf@~1.1.2:
   resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
   integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
 
-oidc-token-hash@^5.0.1, oidc-token-hash@^5.0.3:
+oidc-token-hash@^5.0.3:
   version "5.0.3"
   resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
   integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
@@ -11708,11 +11545,6 @@ on-finished@2.4.1:
   dependencies:
     ee-first "1.1.1"
 
-on-headers@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
-  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
-
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
@@ -11770,19 +11602,6 @@ openapi-typescript@^6.5.5, openapi-typescript@^6.7.4:
     undici "^5.28.2"
     yargs-parser "^21.1.1"
 
-openid-client@^4.9.1:
-  version "4.9.1"
-  resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-4.9.1.tgz#4f00a9d1566c0fa08f0dd5986cf0e6b1e5d14186"
-  integrity sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==
-  dependencies:
-    aggregate-error "^3.1.0"
-    got "^11.8.0"
-    jose "^2.0.5"
-    lru-cache "^6.0.0"
-    make-error "^1.3.6"
-    object-hash "^2.0.1"
-    oidc-token-hash "^5.0.1"
-
 openid-client@^5.2.1:
   version "5.6.5"
   resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.5.tgz#c149ad07b9c399476dc347097e297bbe288b8b00"
@@ -11810,11 +11629,6 @@ ospath@^1.2.2:
   resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b"
   integrity sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==
 
-p-cancelable@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
-  integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==
-
 p-limit@^2.0.0, p-limit@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@@ -12500,11 +12314,6 @@ queue-microtask@^1.2.2:
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
-quick-lru@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
-  integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
-
 quickselect@^1.0.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-1.1.1.tgz#852e412ce418f237ad5b660d70cffac647ae94c2"
@@ -12879,11 +12688,6 @@ resize-observer-polyfill@^1.5.1:
   resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
   integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
 
-resolve-alpn@^1.0.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
-  integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==
-
 resolve-cwd@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"
@@ -12945,13 +12749,6 @@ resolve@^2.0.0-next.5:
     path-parse "^1.0.7"
     supports-preserve-symlinks-flag "^1.0.0"
 
-responselike@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc"
-  integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==
-  dependencies:
-    lowercase-keys "^2.0.0"
-
 restore-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@@ -13425,11 +13222,6 @@ statuses@2.0.1:
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
   integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
 
-"statuses@>= 1.5.0 < 2":
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
-
 stop-iteration-iterator@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"


Reviews:
user: login: peterdudfield
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:24:38Z
user: login: suvanbanerjee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:29:08Z
user: login: peterdudfield
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:31:26Z
user: login: peterdudfield
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:31:37Z
user: login: peterdudfield
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:34:54Z
user: login: peterdudfield
body: I left a few minor comments, but other than that it looks great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:35:43Z

Review comments:
diff_hunk: @@ -573,3 +574,26 @@ export const createBucketObject: (
     increment
   };
 };
+export function calculateChartYMax(
path: apps/nowcasting-app/components/helpers/utils.ts
user: login: peterdudfield
body: I have a slightly worry (but not a solution) that this might cause some odd behaviour. If we have 
- a week > 12 GW then the y axis will go up to 15GW, 
- but if we then have a low week of 7GW, then the y axis will be at 8GW. 

This might be confusing for the user as at a glance the both weeks (with out looking at the scale) then it will look like the same amount of solar. 

It would be nice if the app knew what the highest amount of solar was, and the scaled due to that. perhaps a way to do that, is to have an api end point which provides the highest solar generation values, and then use this to scale the y axis
created_at: 2025-04-10T18:24:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -573,3 +574,26 @@ export const createBucketObject: (
     increment
   };
 };
+export function calculateChartYMax(
path: apps/nowcasting-app/components/helpers/utils.ts
user: login: suvanbanerjee
body: @peterdudfield there is a lower bound of 12GW, if the value is lower than 12GW it will still show 12GW to maintain consistency

https://github.com/openclimatefix/quartz-frontend/pull/561#discussion_r2010045477
created_at: 2025-04-10T18:29:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -400,6 +406,60 @@ export const getOldestTimestampFromCompactForecastValues = <
   );
 };
 
+/**
+ * Retrieves the oldest timestamp from a given `ForecastData` array.
+ *
+ * This function sorts the provided forecast data by the `targetTime` property in ascending order
+ * and returns the earliest (oldest) timestamp. If the array is empty or no valid `targetTime` is found,
+ * it defaults to returning an empty string.
+ *
+ * @param {ForecastData} forecastValues - An array of forecast data objects, each containing a `targetTime` property.
+ * @returns {string} The oldest timestamp as a string, or an empty string if no valid timestamp is found.
+ *
+ * @example
+ * const forecastValues = [
+ *   { targetTime: "2023-12-25T12:00:00Z", ... },
+ *   { targetTime: "2023-12-24T12:00:00Z", ... },
+ *   { targetTime: "2023-12-26T12:00:00Z", ... }
+ * ];
+ * console.log(getOldestTimestampFromForecastValues(forecastValues));
+ * // Output: "2023-12-24T12:00:00Z"
+ */
+export const getOldestTimestampFromForecastValues = (forecastValues: ForecastData): string => {
+  const sortedForecast = [...forecastValues].sort((a, b) => {
+    return a.targetTime > b.targetTime ? 1 : -1;
+  });
+  return sortedForecast?.[0]?.targetTime || "";
+};
+
+/**
path: apps/nowcasting-app/components/helpers/data.ts
user: login: peterdudfield
body: thanks for all these comments
created_at: 2025-04-25T08:31:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+import { getTicks } from "./chartUtils";
+import { describe, expect, it } from "@jest/globals";
+
+describe("getTicks", () => {
+  it("should return ticks for a yMax divisible by 3", () => {
+    const ticks = getTicks(270, []);
+    expect(ticks).toEqual([90, 180, 270]);
+  });
+
+  it("should return ticks for a yMax divisible by 4", () => {
+    const ticks = getTicks(400, []);
+    expect(ticks).toEqual([100, 200, 300, 400]);
+  });
+
+  it("should return ticks for a yMax divisible by 5", () => {
+    const ticks = getTicks(500, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500]);
+  });
+
+  it("should return ticks for a yMax divisible by 6", () => {
+    const ticks = getTicks(600, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600]);
+  });
+
+  it("should return ticks for a yMax divisible by 7", () => {
+    const ticks = getTicks(700, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600, 700]);
+  });
+
+  it("should return the correct ticks for a yMax of 13000", () => {
+    const ticks = getTicks(13000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000]);
+  });
+
+  it("should return the correct ticks for a yMax of 14000", () => {
+    const ticks = getTicks(14000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000]);
+  });
+
+  it("should return the correct ticks for a yMax of 15000", () => {
+    const ticks = getTicks(15000, []);
+    expect(ticks).toEqual([3000, 6000, 9000, 12000, 15000]);
+  });
+
+  it("should return the correct ticks for a yMax of 50", () => {
+    const ticks = getTicks(50, []);
+    expect(ticks).toEqual([10, 20, 30, 40, 50]);
+  });
+
+  it("should return the correct ticks for a yMax of 100", () => {
+    const ticks = getTicks(100, []);
+    expect(ticks).toEqual([20, 40, 60, 80, 100]);
+  });
+
+  it("should return the correct ticks for a yMax of 150", () => {
+    const ticks = getTicks(150, []);
+    expect(ticks).toEqual([30, 60, 90, 120, 150]);
+  });
+
+  it("should return the correct ticks for a yMax of 200", () => {
+    const ticks = getTicks(200, []);
+    expect(ticks).toEqual([40, 80, 120, 160, 200]);
+  });
+
+  it("should return the correct ticks for a yMax of 250", () => {
+    const ticks = getTicks(250, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250]);
+  });
+
+  it("should return the correct ticks for a yMax of 300", () => {
+    const ticks = getTicks(300, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250, 300]);
+  });
+
+  it("should return the correct ticks for a yMax of 350", () => {
+    const ticks = getTicks(350, []);
+    expect(ticks).toEqual([50, 100, 150, 200, 250, 300, 350]);
+  });
+
+  it("should return the correct ticks for a yMax of 400", () => {
+    const ticks = getTicks(400, []);
+    expect(ticks).toEqual([100, 200, 300, 400]);
+  });
+
+  it("should return the correct ticks for a yMax of 450", () => {
+    const ticks = getTicks(450, []);
+    expect(ticks).toEqual([150, 300, 450]);
+  });
+
+  it("should return the correct ticks for a yMax of 500", () => {
+    const ticks = getTicks(500, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500]);
+  });
+
+  it("should return the correct ticks for a yMax of 600", () => {
+    const ticks = getTicks(600, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600]);
+  });
+
+  it("should return the correct ticks for a yMax of 700", () => {
+    const ticks = getTicks(700, []);
+    expect(ticks).toEqual([100, 200, 300, 400, 500, 600, 700]);
+  });
+
+  it("should use fallback for non-round divisors", () => {
+    const ticks = getTicks(37, []);
+    // For yMax > 10, the fallback is 50
+    // But since 50 > 37, there's only one tick
+    expect(ticks).toEqual([]);
+  });
+
+  it("should use different fallbacks based on yMax size", () => {
+    const smallTicks = getTicks(3, []);
+    const mediumTicks = getTicks(15, []);
+    const largeTicks = getTicks(600, []);
+
+    expect(smallTicks.length).toBeGreaterThan(0);
+    expect(mediumTicks.length).toBeGreaterThan(0);
+    expect(largeTicks.length).toBeGreaterThan(0);
+  });
+
+  it("should handle decimal yMax values", () => {
+    const ticks = getTicks(2.5, []);
+    expect(ticks).toEqual([0.5, 1, 1.5, 2, 2.5]);
+  });
+
+  it("should handle zero and negative yMax", () => {
+    expect(getTicks(0, [])).toEqual([]);
+    expect(getTicks(-10, [])).toEqual([]);
+  });
+});
path: apps/nowcasting-app/components/helpers/chartUtils.test.ts
user: login: peterdudfield
body: thanks for the tests
created_at: 2025-04-25T08:31:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -573,3 +574,26 @@ export const createBucketObject: (
     increment
   };
 };
+export function calculateChartYMax(
+  chartData: ChartData[],
path: apps/nowcasting-app/components/helpers/utils.ts
user: login: peterdudfield
body: worth a function description in here?, to make it clear what maxY does. 

In some ways this in maxY is avtually mininum, I think its the minimum the value that is returned
created_at: 2025-04-25T08:34:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-07T15:54:08Z
body: [vc]: #ZQrTXhj5xUlJPMXA2Qot5LrZs80Y4hcoWDMKSWwGryg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJub3djYXN0aW5nLWFwcCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL25vd2Nhc3RpbmctYXBwIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjoyMywidW5yZXNvbHZlZCI6MCwidG90YWwiOjIzLCJwYWdlIjoiLyIsImxpbmsiOiJub3djYXN0aW5nLWFwcC1naXQtZGV2ZWxvcG1lbnQtb3BlbmNsaW1hdGVmaXgudmVyY2VsLmFwcC8ifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL29wZW5jbGltYXRlZml4L25vd2Nhc3RpbmctYXBwLzVxTnlUUTJ4NDZtQmpaTXQzTjJISDF3Nmp2MVciLCJwcmV2aWV3VXJsIjoibm93Y2FzdGluZy1hcHAtZ2l0LWRldmVsb3BtZW50LW9wZW5jbGltYXRlZml4LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJxdWFydHotYXBwIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvcXVhcnR6LWFwcCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL29wZW5jbGltYXRlZml4L3F1YXJ0ei1hcHAvQlpRRnRydmc1a0RqMmJoMzZtWks1dVRxbm9hWCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InF1YXJ0ei1hcHAtZ2l0LWRldmVsb3BtZW50LW9wZW5jbGltYXRlZml4LnZlcmNlbC5hcHAifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **nowcasting-app** | ✅ Ready ([Inspect](https://vercel.com/openclimatefix/nowcasting-app/5qNyTQ2x46mBjZMt3N2HH1w6jv1W)) | [Visit Preview](https://nowcasting-app-git-development-openclimatefix.vercel.app) | ✅ [23 resolved](https://vercel.live/open-feedback/nowcasting-app-git-development-openclimatefix.vercel.app?via=pr-comment-feedback-link&nextHash=show-comment-archive%3Dtrue) | Apr 25, 2025 7:58am |

<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **quartz-app** | ⬜️ Ignored ([Inspect](https://vercel.com/openclimatefix/quartz-app/BZQFtrvg5kDj2bh36mZK5uTqnoaX)) | [Visit Preview](https://quartz-app-git-development-openclimatefix.vercel.app) |  | Apr 25, 2025 7:58am |
</details>



author_association: NONE