diff --git a/cypress.config.ts b/cypress.config.ts
index 17161e32..85d2e5b3 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -1,9 +1,15 @@
-import { defineConfig } from "cypress";
+import { defineConfig } from "cypress"
+import { configureVisualRegression } from "cypress-visual-regression/dist/plugin"
export default defineConfig({
- e2e: {
- setupNodeEvents(on, config) {
- // implement node event listeners here
+ e2e: {
+ setupNodeEvents(on, config) {
+ // implement node event listeners here
+ configureVisualRegression(on)
+ },
+ env: {
+ visualRegressionType: "regression",
+ },
+ screenshotsFolder: "./cypress/snapshots/actual",
},
- },
-});
+})
diff --git a/cypress/e2e/cms/pollenCalendar.cy.ts b/cypress/e2e/cms/pollenCalendar.cy.ts
new file mode 100644
index 00000000..7a8353b6
--- /dev/null
+++ b/cypress/e2e/cms/pollenCalendar.cy.ts
@@ -0,0 +1,139 @@
+import { URLS } from "../consts"
+import { loginAttempt, mockValidCredentials, userIsLoggedIn } from "../authenticationUtils"
+
+function loginToEditPollenPage() {
+ cy.visit(URLS.EDIT_POLLEN_DATA)
+ mockValidCredentials()
+ loginAttempt()
+ userIsLoggedIn()
+ cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here (task #70)
+ cy.url().should("eq", URLS.EDIT_POLLEN_DATA)
+}
+
+function inputDataFile(filepath: string = "cypress/fixtures/Pollen Dummy Data - valid format.xlsx") {
+ cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
+ .find("input[type='file']")
+ .selectFile(filepath)
+}
+
+describe("updating pollen calendar", () => {
+ beforeEach(loginToEditPollenPage)
+
+ it("successfully preview calendar using valid excel spreadsheet", () => {
+ inputDataFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains("Preview generated ✅")
+
+ cy.contains("button", "Update calendar on website").click()
+ })
+
+ describe("invalid filetypes", () => {
+ it("no file", () => {
+ cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data").find("input[type='file']")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains("No file uploaded.")
+ cy.contains("Preview generated ✅").should("not.exist")
+ })
+
+ it("invalid file", () => {
+ inputDataFile("cypress/fixtures/authSuccessResponse.json")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains("Uploaded file 'authSuccessResponse.json' is not a .xlsx Excel spreadsheet.")
+ })
+ })
+
+ describe("invalid excel spreadsheet format", () => {
+ // invalid formatsare formats that ignore an assumptions made by parsing algorithm
+
+ it("has no worksheets with 'raw' in the name", () => {
+ inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - no 'raw' worksheet.xlsx")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains(
+ "This spreadsheet has no worksheet with 'raw' in its name. Names of the worksheets in this spreadsheet: 2022_23_percentage, 2023_24, 2023_24_percentage, 2022_23_old"
+ )
+ cy.contains("Preview generated ✅").should("not.exist")
+ })
+
+ it("pollen types are not in column A", () => {
+ inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - pollen types in column B.xlsx")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains(
+ `Cell A2 doesn't seem to be a pollen name: undefined. Values in Column A from Row 2 onwards are expected to be pollen names, and the value in the last populated Row in Column A should be "Total pollen counted".`
+ )
+ cy.contains("Preview generated ✅").should("exist") // the other worksheet has a valid format though so preview that data
+ })
+
+ it.skip("dates are not in row 1", () => {
+ inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - dates in row 2.xlsx")
+
+ cy.contains("button", "Preview data").click()
+ cy.contains("Cell B1 doesn't seem to be a date: Pollen Data 2025.")
+ cy.contains("Preview generated ✅").should("exist") // other worksheet has valid format
+ })
+
+ it("doesn't have 'Total pollen counted'", () => {
+ inputDataFile(
+ "cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx"
+ )
+
+ cy.contains("button", "Preview data").click()
+ cy.contains("Worksheet '2023_24_raw' couldn't be parsed because this error occurred:")
+ cy.contains("A cell containing 'Total pollen counted' was not found in Column A.")
+ cy.contains("Preview generated ✅").should("not.exist")
+ })
+ })
+
+ describe("showing calendar preview", () => {
+ beforeEach(() => inputDataFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx"))
+
+ // both tests won't work as is because canvas is a visual element
+ // and its children elements are picked up by the browser :`(
+ it.skip("chart with a labelled x and y axis", () => {
+ cy.contains("button", "Preview data").click()
+
+ const yAxisLabel = "Pollen grains per cubic metre of air"
+ const xAxisLabel = "Date"
+ cy.contains(yAxisLabel).should("have.length", 1)
+ cy.contains(xAxisLabel).should("have.length", 1)
+ })
+
+ it.skip("check tooltip of a pollen data point", () => {
+ cy.contains("button", "Preview data").click()
+
+ cy.get("#toolTipButton").trigger("mouseover")
+
+ cy.contains("You hovered over the Button").should("be.visible")
+ })
+
+ it("pollen calendar looks as it currently does lol", () => {
+ cy.contains("button", "Preview data").click()
+ cy.wait(5000)
+
+ // if it looks '50%' or more different to base snapshot then test fails
+ cy.compareSnapshot("editPollenCalendarPreview", { errorThreshold: 50 })
+ cy.get("canvas").compareSnapshot("editPollenCalendarPreview", { errorThreshold: 50 })
+ })
+
+ it("pollen calendar hover on data point should look as it does right now", () => {
+ cy.contains("button", "Preview data").click()
+ cy.wait(5000)
+
+ cy.get("canvas").then(($canvas) => {
+ const canvasWidth = $canvas.width()
+ const canvasHeight = $canvas.height()
+
+ let buttonX = canvasWidth * 0.53052
+ let buttonY = canvasHeight * 0.6427
+
+ cy.wrap($canvas).scrollIntoView().realTouch({ x: buttonX, y: buttonY })
+ cy.wait(5000)
+ cy.get("canvas").compareSnapshot("pollenCalendarDataPointHover1")
+ })
+ })
+ })
+})
diff --git a/cypress/e2e/cms/pollenData.cy.ts b/cypress/e2e/cms/pollenData.cy.ts
deleted file mode 100644
index 80944174..00000000
--- a/cypress/e2e/cms/pollenData.cy.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { URLS } from "../consts"
-import { loginAttempt, mockValidCredentials, userIsLoggedIn } from "../authenticationUtils"
-
-describe("pollen data", () => {
- describe("filetype of inputted file", () => {
- beforeEach(() => {
- cy.visit(URLS.EDIT_POLLEN_DATA)
- mockValidCredentials()
- loginAttempt()
- userIsLoggedIn()
- cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here!!!!
- })
-
- it("valid excel file", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx")
-
- cy.contains("button", "Preview data").click()
- cy.contains("Preview generated ✅")
- })
-
- it("no file", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data").find("input[type='file']")
-
- cy.contains("button", "Preview data").click()
- cy.contains("No file uploaded.")
- cy.contains("Preview generated ✅").should("not.exist")
- })
-
- it("invalid file", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile("cypress/fixtures/authSuccessResponse.json")
-
- cy.contains("button", "Preview data").click()
-
- cy.contains("Uploaded file 'authSuccessResponse.json' is not a .xlsx Excel spreadsheet.")
- })
- })
-
- describe("excel spreadsheet format", () => {
- beforeEach(() => {
- cy.visit(URLS.EDIT_POLLEN_DATA)
- mockValidCredentials()
- loginAttempt()
- userIsLoggedIn()
- cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here!!!!
- })
- // correct format test is already in the 'inputting excel file' suite
- // so testing all invalid formats
- // which are formats that ignore an assumptions made by parsing algorithm
-
- it("No worksheets containing 'raw' in name", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - no 'raw' worksheet.xlsx")
-
- cy.contains("button", "Preview data").click()
- cy.contains("This spreadsheet has no worksheet with 'raw' in its name.")
- cy.contains("Preview generated ✅").should("not.exist")
- })
-
- it("Pollen types not in Column A", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - pollen types in column B.xlsx")
-
- cy.contains("button", "Preview data").click()
- cy.contains(`Cell A2 doesn't seem to be a pollen name: undefined.`)
- cy.contains("Preview generated ✅").should("not.exist")
- })
-
- it.skip("Dates are not in row 1", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - dates in row 2.xlsx")
-
- cy.contains("button", "Preview data").click()
- cy.contains(`Cell B1 doesn't seem to be a date: Pollen Data 2025.`)
- cy.contains("Preview generated ✅").should("not.exist")
- })
-
- it("doesn't have 'Total pollen counted'", () => {
- cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data")
- .find("input[type='file']")
- .selectFile(
- "cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx"
- )
-
- cy.contains("button", "Preview data").click()
- cy.contains("Worksheet '2023_24_raw' couldn't be parsed because this error occurred:")
- cy.contains(`A cell containing 'Total pollen counted' was not found in Column A.`)
- cy.contains("Preview generated ✅").should("not.exist")
- })
- })
-})
diff --git a/cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx b/cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx
similarity index 68%
rename from cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx
rename to cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx
index 25c44c05..73fc1a55 100644
Binary files a/cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx and b/cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx differ
diff --git a/cypress/snapshots/.gitignore b/cypress/snapshots/.gitignore
new file mode 100644
index 00000000..14441502
--- /dev/null
+++ b/cypress/snapshots/.gitignore
@@ -0,0 +1,2 @@
+actual
+diff
\ No newline at end of file
diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png b/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png
new file mode 100644
index 00000000..acb4eba4
Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png differ
diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png
new file mode 100644
index 00000000..616a12ff
Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png differ
diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png
new file mode 100644
index 00000000..8f55da71
Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png differ
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
index 02867c16..ae56c631 100644
--- a/cypress/support/e2e.ts
+++ b/cypress/support/e2e.ts
@@ -14,13 +14,17 @@
// ***********************************************************
// Import commands.js using ES2015 syntax:
-import './commands'
+import "./commands"
-Cypress.on('uncaught:exception', (err, runnable) => {
+import "cypress-real-events/support"
+import { addCompareSnapshotCommand } from "cypress-visual-regression/dist/command"
+addCompareSnapshotCommand()
+
+Cypress.on("uncaught:exception", (err, runnable) => {
// returning false here prevents Cypress from
// failing the test
return false
- })
+})
// Alternatively you can use CommonJS syntax:
-// require('./commands')
\ No newline at end of file
+// require('./commands')
diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json
new file mode 100644
index 00000000..6c787768
--- /dev/null
+++ b/cypress/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "types": ["cypress", "cypress-visual-regression", "cypress-real-events"],
+ "resolveJsonModule": true
+ }
+}
diff --git a/frontend/app/(cms)/pollen/components/DateInput.tsx b/frontend/app/(cms)/pollen/components/DateInput.tsx
new file mode 100644
index 00000000..a14264fc
--- /dev/null
+++ b/frontend/app/(cms)/pollen/components/DateInput.tsx
@@ -0,0 +1,34 @@
+import dayjs from "dayjs"
+
+export default function DateInput({ lowerLimit, upperLimit, setUpperLimit, setLowerLimit }: any) {
+ const lowerLimitString = dayjs(lowerLimit).format("YYYY-MM-DD")
+ const upperLimitString = dayjs(upperLimit).format("YYYY-MM-DD")
+
+ function makeTimestampForDateMidday(timestamp: number) {
+ return dayjs(timestamp).set("hour", 0).set("minute", 0).set("second", 0).set("millisecond", 0).valueOf()
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/(cms)/pollen/components/MultiChart.tsx b/frontend/app/(cms)/pollen/components/MultiChart.tsx
new file mode 100644
index 00000000..4b13a9a8
--- /dev/null
+++ b/frontend/app/(cms)/pollen/components/MultiChart.tsx
@@ -0,0 +1,138 @@
+import dayjs from "dayjs"
+import { FormattedPollenData } from "./util/formatData"
+import { memo } from "react"
+
+import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"
+import "chart.js/auto"
+import { Chart } from "react-chartjs-2"
+
+import { ChartOptions } from "chart.js"
+
+// includes bar chart & line chart on same axis
+const MultiChart = memo(function MultiChart({
+ dateUpperLimit = dayjs("2024-07-24").valueOf(),
+ dateLowerLimit = dayjs("2024-08-24").valueOf(),
+ pollenData,
+}: {
+ dateUpperLimit: number
+ dateLowerLimit: number
+ pollenData: FormattedPollenData
+}) {
+ const { pollenTypes, pollenValues, dailyTotals } = pollenData
+
+ const dateFormat = "DD/MM/YY"
+ const chartData = {
+ labels: pollenValues[0].map(({ x }) => dayjs(x).valueOf()),
+ datasets: [
+ ...pollenTypes.map((pollenType, index) => {
+ const numPollenTypes = pollenTypes.length
+ const towerEffectWeighting = 0.4
+
+ return {
+ label: pollenType,
+ data: pollenValues[index].map(({ x, y }) => ({ x, y: y * -1 })),
+ categoryPercentage:
+ 1 - towerEffectWeighting + (towerEffectWeighting * (numPollenTypes - index)) / numPollenTypes,
+ barPercentage: 0.8,
+ type: "bar",
+ stack: "barStack",
+ backgroundColor: selectColour(index),
+ borderColor: selectColour(index),
+ }
+ }),
+
+ ...pollenTypes.map((pollenType, index) => {
+ return {
+ data: pollenValues[index],
+ label: pollenType,
+ type: "line",
+ borderColor: selectColour(index),
+ backgroundColor: selectColour(index),
+ }
+ }),
+
+ {
+ label: "Total Pollen",
+ data: dailyTotals,
+ borderColor: "black",
+
+ borderDash: [5, 5],
+ type: "line",
+ },
+ ],
+ }
+ const chartOptions: ChartOptions = {
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ title: function (context) {
+ return "Date: " + dayjs(context[0].parsed.x).format(dateFormat)
+ },
+ label: function (context) {
+ let label = context.dataset.label || ""
+
+ if (label) {
+ label += ": "
+ }
+ if (context.parsed.y !== null) {
+ label += Math.abs(context.parsed.y)
+ }
+ return label
+ },
+ },
+ },
+ },
+
+ maintainAspectRatio: false,
+ parsing: false,
+
+ scales: {
+ x: {
+ type: "time",
+ time: {
+ unit: "day",
+ },
+
+ min: dateLowerLimit,
+ max: dateUpperLimit + 50, //+50ms so right most pollen values don't get cut off from chart
+ title: { text: "Date", display: true },
+ ticks: {
+ // For a category axis, the val is the index so the lookup via getLabelForValue is needed
+ callback: function (val: string | number) {
+ return dayjs(val).format(dateFormat)
+ },
+ },
+ },
+
+ y: {
+ suggestedMin: -10,
+ suggestedMax: 10,
+ title: {
+ display: true,
+ text: "Pollen grains per cubic metre of air",
+ },
+
+ ticks: {
+ // For a category axis, the val is the index so the lookup via getLabelForValue is needed
+ callback: function (val: string | number, index: number) {
+ return Math.abs(val as number)
+ },
+ },
+ },
+ },
+ }
+
+ function selectColour(number: number) {
+ const hue = number * 137.508 // use golden angle approximation
+ return `hsl(${hue},50%,75%)`
+ }
+
+ return (
+
+ {chartData && }
+
+ )
+})
+
+export default MultiChart
diff --git a/frontend/app/(cms)/pollen/components/PollenCalendar.tsx b/frontend/app/(cms)/pollen/components/PollenCalendar.tsx
new file mode 100644
index 00000000..735f3dc6
--- /dev/null
+++ b/frontend/app/(cms)/pollen/components/PollenCalendar.tsx
@@ -0,0 +1,102 @@
+import { memo, useEffect, useState } from "react"
+import dayjs from "dayjs"
+import { PollenData } from "@/app/(cms)/pollen/edit/type/PollenDataType"
+import { formatPollenData, FormattedPollenData } from "@/app/(cms)/pollen/components/util/formatData"
+import PollenTypeInput from "@/app/(cms)/pollen/components/PollenTypeInput"
+import DateInput from "@/app/(cms)/pollen/components/DateInput"
+import MultiChart from "@/app/(cms)/pollen/components/MultiChart"
+
+const PollenCalendar = memo(function PollenCalendar({ pollenData }: { pollenData: PollenData[] }) {
+ const [showsDateFilter, setShowsDateFilter] = useState(false)
+ const [dateLowerLimit, setDateLowerLimit] = useState(dayjs("2024-11-28").valueOf())
+ const [dateUpperLimit, setDateUpperLimit] = useState(dayjs("2024-12-2").valueOf())
+
+ const [showsPollenTypeFilter, setShowsPollenTypeFilter] = useState(false)
+ const [allPollenTypes, setAllPollenTypes] = useState([])
+ const [displayedPollenTypes, setDisplayedPollenTypes] = useState([])
+
+ const [formattedPollenData, setFormattedPollenData] = useState(null)
+ const [filteredPollenData, setFilteredPollenData] = useState(null)
+
+ useEffect(() => {
+ const formatted = formatPollenData(pollenData)
+ setFormattedPollenData(formatted)
+
+ const allPollenNames = formatted.pollenTypes.map((pollenType) => pollenType)
+ setAllPollenTypes(allPollenNames)
+ setDisplayedPollenTypes(allPollenNames)
+ }, [pollenData])
+
+ useEffect(() => {
+ if (!formattedPollenData || !displayedPollenTypes.length) return setFilteredPollenData(null)
+
+ const filtered: FormattedPollenData = {
+ dailyTotals: formattedPollenData.dailyTotals,
+ pollenTypes: [],
+ pollenValues: [],
+ }
+
+ formattedPollenData.pollenTypes.map((pollenType, index) => {
+ if (!displayedPollenTypes.includes(pollenType)) return
+
+ filtered.pollenTypes.push(pollenType)
+ filtered.pollenValues.push(formattedPollenData.pollenValues[index])
+ })
+
+ setFilteredPollenData(filtered)
+ }, [formattedPollenData, displayedPollenTypes])
+
+ return (
+ <>
+
+
+
Filter by
+
+
+
+ {allPollenTypes && showsPollenTypeFilter && (
+
+ )}
+
+
+
+
+
+ {showsDateFilter && (
+
+ )}
+
+
+
+ {filteredPollenData && displayedPollenTypes.length ? (
+
+
+
+ ) : (
+
No pollen types selected 🥲
+ )}
+
+ >
+ )
+})
+
+export default PollenCalendar
diff --git a/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx b/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx
new file mode 100644
index 00000000..ad812678
--- /dev/null
+++ b/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx
@@ -0,0 +1,18 @@
+import Select from "react-select"
+
+export default function PollenTypeInput({
+ allPollenTypes,
+ displayPollenTypes,
+}: {
+ allPollenTypes: string[]
+ displayPollenTypes: any
+}) {
+ return (
+