diff --git a/src/finland/OpenSearchAnalyticsContext.tsx b/src/finland/OpenSearchAnalyticsContext.tsx index 2ad9a1d..c6f953a 100644 --- a/src/finland/OpenSearchAnalyticsContext.tsx +++ b/src/finland/OpenSearchAnalyticsContext.tsx @@ -1,11 +1,17 @@ import * as React from "react"; import { createContext, useEffect, useState } from "react"; -import { KeyValuePair } from "./finlandUtils"; +import { + KeyValuePair, + NameValuePair, + loanDurationOptions, + readable, +} from "./finlandUtils"; const termsEndpoint = "/admin/events/terms"; const histogramEndpoint = "/admin/events/histogram"; const facetsEndpoint = "/admin/events/facets"; +const municipalityEndpoint = "/admin/municipalities"; -type BucketItem = { +export type BucketItem = { key: string; doc_count: number; }; @@ -28,12 +34,19 @@ type HistogramData = { export type FacetData = Record; +export type FilterToOptionsFunc = ( + filterKey: string, + buckets: BucketItem[] +) => NameValuePair[]; + export type OpenSearchAnalyticsContextType = { facetData?: FacetData; eventData?: TermBucketData; fetchEventData?: (params: KeyValuePair[]) => void; histogramData?: HistogramData; fetchHistogramData?: (params: KeyValuePair[]) => void; + filterToOptions?: FilterToOptionsFunc; + labelizeFilterChip: (item: KeyValuePair) => string; isReady?: boolean; }; @@ -55,6 +68,9 @@ export function OpenSearchAnalyticsContextProvider({ const [isReady, setIsReady] = useState(false); const [eventData, setEventData] = useState(null); const [histogramData, setHistogramData] = useState(null); + const [municipalityMapping, setMunicipalityMapping] = useState< + Record + >({}); useEffect(() => { async function fetchFacets() { @@ -70,7 +86,22 @@ export function OpenSearchAnalyticsContextProvider({ console.error("Error while fetching facets for statistics", err); } } + async function fetchMunicipalityMappings() { + try { + const response = await fetch(`${municipalityEndpoint}`); + const data = await response.json(); + if (data) { + setMunicipalityMapping(data); + } + } catch (err) { + console.error( + "Error while fetching municipality mapping for statistics", + err + ); + } + } fetchFacets(); + fetchMunicipalityMappings(); }, [library]); async function fetchEventData(selections?: KeyValuePair[]) { @@ -105,6 +136,39 @@ export function OpenSearchAnalyticsContextProvider({ } } + function filterToOptions(filterKey: string, buckets?: BucketItem[]) { + if (filterKey === "duration") { + return [...loanDurationOptions]; // Spread to trigger SelectSearch re-render + } + if (!buckets?.length) { + return []; + } + if (filterKey === "location") { + return buckets + .map((item) => ({ + value: item.key, + name: municipalityMapping[item.key] || item.key, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + return buckets + .map((item) => ({ + value: item.key, + name: item.key, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + function labelizeFilterChip({ key, value }: KeyValuePair) { + if (key === "location") { + return `${readable(key)}: ${municipalityMapping[value] || value}`; + } + if (key === "duration") { + return `${readable(key)}: ${readable(value) || value}`; + } + return `${readable(key)}: ${value}`; + } + return ( {children} diff --git a/src/finland/components/EventBarChart.tsx b/src/finland/components/EventBarChart.tsx index eb2d4c9..9f1516b 100644 --- a/src/finland/components/EventBarChart.tsx +++ b/src/finland/components/EventBarChart.tsx @@ -36,7 +36,13 @@ export function EventBarChart() { setTimeframeOffset, } = useFilters(); - const { facetData, eventData, fetchEventData } = useOpenSearchAnalytics(); + const { + facetData, + eventData, + fetchEventData, + filterToOptions, + labelizeFilterChip, + } = useOpenSearchAnalytics(); useEffect(() => { const selections: KeyValuePair[] = [...activeFilters]; @@ -67,12 +73,14 @@ export function EventBarChart() { setStartDate={setStartDate} endDate={endDate} setEndDate={setEndDate} + filterToOptions={filterToOptions} />
diff --git a/src/finland/components/FilterChips.tsx b/src/finland/components/FilterChips.tsx index 1e6783b..844c32e 100644 --- a/src/finland/components/FilterChips.tsx +++ b/src/finland/components/FilterChips.tsx @@ -1,16 +1,18 @@ import * as React from "react"; -import { KeyValuePair, readable } from "../finlandUtils"; +import { KeyValuePair } from "../finlandUtils"; type FilterChipsProps = { activeFilters: KeyValuePair[]; toggleFilter: (selection: KeyValuePair) => void; clearFilters: () => void; + labelize: (item: KeyValuePair) => string; }; export function FilterChips({ activeFilters, toggleFilter, clearFilters, + labelize, }: FilterChipsProps) { return (
@@ -22,7 +24,7 @@ export function FilterChips({ onClick={() => toggleFilter(item)} isSingle > - {readable(item.key)}: {item.value} + {labelize(item)} ))} void; endDate: string; setEndDate: (newDate: string) => void; + filterToOptions: FilterToOptionsFunc; }; export function FilterInputs({ @@ -20,15 +21,12 @@ export function FilterInputs({ setStartDate, endDate, setEndDate, + filterToOptions, }: FilterInputsProps) { return (
{filterKeys.map((key) => { - const options = - facetData[key]?.buckets.map((item) => ({ - value: item.key, - name: item.key, - })) || []; + const options = filterToOptions(key, facetData[key]?.buckets); const label = readable(key); return (
diff --git a/src/finland/components/Histogram.tsx b/src/finland/components/Histogram.tsx index fd8313a..3a2ba25 100644 --- a/src/finland/components/Histogram.tsx +++ b/src/finland/components/Histogram.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { LineChart, Line, @@ -33,11 +33,17 @@ import { useOpenSearchAnalytics } from "../hooks/useOpenSearchAnalytics"; export function Histogram() { const [interval, setInterval] = useState("hour"); const [inactiveGroups, setInactiveGroups] = useState([]); + const lastLegendClick: React.MutableRefObject<{ + key: string | null; + time: number | null; + }> = useRef({ key: null, time: null }); const { facetData, histogramData, fetchHistogramData, + filterToOptions, + labelizeFilterChip, } = useOpenSearchAnalytics(); const { @@ -105,6 +111,10 @@ export function Histogram() { }); }, [histogramData]); + if (!timeData || !facetData) return null; + + const dateRangeLength = daysBetween(startDate, endDate); + function toggleGroup(key: string) { setInactiveGroups( inactiveGroups.includes(key) @@ -113,9 +123,24 @@ export function Histogram() { ); } - if (!timeData || !facetData) return null; + function toggleGroupSolo(key: string) { + setInactiveGroups( + inactiveGroups.length <= 1 ? eventKeys.filter((item) => item !== key) : [] + ); + } - const dateRangeLength = daysBetween(startDate, endDate); + function handleLegendClick(key: string) { + const now = Date.now(); + if ( + lastLegendClick.current.key === key && + now - lastLegendClick.current.time < 300 + ) { + toggleGroupSolo(key); + } else { + toggleGroup(key); + } + lastLegendClick.current = { key, time: now }; + } return (
@@ -126,12 +151,14 @@ export function Histogram() { setStartDate={setStartDate} endDate={endDate} setEndDate={setEndDate} + filterToOptions={filterToOptions} />
@@ -173,7 +200,7 @@ export function Histogram() { timeStampToLabel(unixTime as number, interval) } /> - toggleGroup(item.dataKey)} /> + handleLegendClick(item.dataKey)} /> {eventKeys.map((item, idx) => ( = { [eventTypes.circulation_manager_fulfill]: "Toimitukset", [eventTypes.circulation_manager_hold_place]: "Varaukset, pyyntö", [eventTypes.circulation_manager_hold_release]: "Varaukset, nouto", + [eventTypes.circulation_manager_new_patron]: "Uudet asiakkaat", [eventTypes.distributor_check_out]: "Lainaukset", [eventTypes.distributor_check_in]: "Palautukset", [eventTypes.distributor_hold_place]: "Varaukset, pyyntö", @@ -71,16 +76,28 @@ export const readableNames: Record = { fiction: "Fiktio", genres: "Genre", audience: "Kohderyhmä", + location: "Sijainti", + duration: "Laina-aika (palautukset)", // Time intervals hour: "Tunti", day: "Päivä", month: "Kuukausi", + // Loan duration options: + under_2h: "Alle 2h", + over_2h: "Yli 2h", }; export const readable = (input: string): string => readableNames[input] || input; -export const filterKeys = ["publisher", "language", "audience", "genres"]; +export const filterKeys = [ + "publisher", + "language", + "audience", + "genres", + "location", + "duration", +]; export const intervals = ["hour", "day", "month"]; export type Interval = "hour" | "day" | "month"; @@ -123,6 +140,13 @@ export const timeframeOptions: Record = { }, }; +const loanDurations = ["under_2h", "over_2h"]; + +export const loanDurationOptions = loanDurations.map((key) => ({ + value: key, + name: readable(key), +})); + /* * General helper functions */ diff --git a/src/finland/tests/EventBarChart.test.tsx b/src/finland/tests/EventBarChart.test.tsx index 60309be..6782946 100644 --- a/src/finland/tests/EventBarChart.test.tsx +++ b/src/finland/tests/EventBarChart.test.tsx @@ -6,6 +6,7 @@ import { eventDataFixture, histogramDataFixture, } from "./fixtures"; +import { mockFilterToOptions } from "./mocks"; /* Mock globals and dependencies */ @@ -57,10 +58,11 @@ jest.mock("../hooks/useOpenSearchAnalytics", () => ({ histogramData: histogramDataFixture.data, fetchHistogramData: jest.fn(() => null), isReady: true, + filterToOptions: jest.fn(mockFilterToOptions), })), })); -const eventTypesLenght = eventDataFixture.data.type.length; +const eventTypesLength = eventDataFixture.data.type.length; afterAll(() => { jest.resetAllMocks(); @@ -73,6 +75,6 @@ describe("EventBarChart", () => { const bars = document.querySelectorAll(".recharts-bar-rectangle"); // Check that there's as many bars as event types - expect(bars.length === eventTypesLenght).toBeTruthy(); + expect(bars.length === eventTypesLength).toBeTruthy(); }); }); diff --git a/src/finland/tests/FilterChips.test.tsx b/src/finland/tests/FilterChips.test.tsx index 7815f47..d8d7c07 100644 --- a/src/finland/tests/FilterChips.test.tsx +++ b/src/finland/tests/FilterChips.test.tsx @@ -22,6 +22,7 @@ describe("FilterChips", () => { activeFilters={activeFilters} toggleFilter={doNothing} clearFilters={doNothing} + labelize={(item) => `${item.key}: ${item.value}`} /> ); @@ -39,6 +40,7 @@ describe("FilterChips", () => { activeFilters={activeFilters} toggleFilter={toggleFilterMock} clearFilters={doNothing} + labelize={(item) => `${item.key}: ${item.value}`} /> ); @@ -57,6 +59,7 @@ describe("FilterChips", () => { activeFilters={activeFilters} toggleFilter={doNothing} clearFilters={clearFiltersMock} + labelize={(item) => `${item.key}: ${item.value}`} /> ); diff --git a/src/finland/tests/FilterInputs.test.tsx b/src/finland/tests/FilterInputs.test.tsx index d4ca443..5a151e9 100644 --- a/src/finland/tests/FilterInputs.test.tsx +++ b/src/finland/tests/FilterInputs.test.tsx @@ -3,6 +3,7 @@ import { render, fireEvent, screen } from "@testing-library/react"; import { FilterInputs } from "../components/FilterInputs"; import { facetDataFixture } from "./fixtures"; import { filterKeys } from "../finlandUtils"; +import { mockFilterToOptions } from "./mocks"; function doNothing() { /* do nothing */ @@ -24,6 +25,7 @@ describe("FilterInputs", () => { setStartDate={doNothing} endDate="" setEndDate={doNothing} + filterToOptions={mockFilterToOptions} /> ); @@ -47,6 +49,7 @@ describe("FilterInputs", () => { setStartDate={doNothing} endDate="" setEndDate={doNothing} + filterToOptions={mockFilterToOptions} /> ); @@ -80,6 +83,7 @@ describe("FilterInputs", () => { setStartDate={setStartDateMock} endDate="2023-12-31" setEndDate={setEndDateMock} + filterToOptions={mockFilterToOptions} /> ); diff --git a/src/finland/tests/FinlandStats.test.tsx b/src/finland/tests/FinlandStats.test.tsx index 483afa9..22e539c 100644 --- a/src/finland/tests/FinlandStats.test.tsx +++ b/src/finland/tests/FinlandStats.test.tsx @@ -7,6 +7,7 @@ import { facetDataFixture, histogramDataFixture, } from "./fixtures"; +import { mockFilterToOptions } from "./mocks"; /* Mock globals and dependencies */ @@ -42,6 +43,7 @@ jest.mock("../hooks/useOpenSearchAnalytics", () => ({ histogramData: histogramDataFixture.data, fetchHistogramData: jest.fn(() => null), isReady: true, + filterToOptions: jest.fn(mockFilterToOptions), })), })); diff --git a/src/finland/tests/Histogram.test.tsx b/src/finland/tests/Histogram.test.tsx index 246a26e..c44c41d 100644 --- a/src/finland/tests/Histogram.test.tsx +++ b/src/finland/tests/Histogram.test.tsx @@ -2,10 +2,11 @@ import * as React from "react"; import { render } from "@testing-library/react"; import { Histogram } from "../components/Histogram"; import { - facetDataFixture, eventDataFixture, + facetDataFixture, histogramDataFixture, } from "./fixtures"; +import { mockFilterToOptions } from "./mocks"; /* Mock globals and dependencies */ @@ -57,10 +58,11 @@ jest.mock("../hooks/useOpenSearchAnalytics", () => ({ histogramData: histogramDataFixture.data, fetchHistogramData: jest.fn(() => null), isReady: true, + filterToOptions: jest.fn(mockFilterToOptions), })), })); -const eventTypesLenght = eventDataFixture.data.type.length; +const eventTypesLength = eventDataFixture.data.type.length; afterAll(() => { jest.resetAllMocks(); @@ -73,11 +75,11 @@ describe("Histogram", () => { const lines = document.querySelectorAll(".recharts-line"); // Check that there's as many bars as event types - for (let i = 0; i < eventTypesLenght; i++) { + for (let i = 0; i < eventTypesLength; i++) { expect(lines[i]).toBeInTheDocument(); } // ... but no more than that - expect(lines[eventTypesLenght]).toBeFalsy(); + expect(lines[eventTypesLength]).toBeFalsy(); }); it("toggles a line when clicking a legend", () => { @@ -86,6 +88,6 @@ describe("Histogram", () => { const lines = document.querySelectorAll(".recharts-legend-item"); // Check that there's as many lines as event types - expect(lines.length === eventTypesLenght).toBeTruthy(); + expect(lines.length === eventTypesLength).toBeTruthy(); }); }); diff --git a/src/finland/tests/mocks.ts b/src/finland/tests/mocks.ts new file mode 100644 index 0000000..3841b24 --- /dev/null +++ b/src/finland/tests/mocks.ts @@ -0,0 +1,17 @@ +import { BucketItem } from "../OpenSearchAnalyticsContext"; +import { loanDurationOptions } from "../finlandUtils"; + +export function mockFilterToOptions(filterKey: string, buckets?: BucketItem[]) { + if (filterKey === "duration") { + return [...loanDurationOptions]; + } + if (!buckets?.length) { + return []; + } + return buckets + .map((item) => ({ + value: item.key, + name: item.key, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/stylesheets/finland_statistics.scss b/src/stylesheets/finland_statistics.scss index b88c671..5d05b7c 100644 --- a/src/stylesheets/finland_statistics.scss +++ b/src/stylesheets/finland_statistics.scss @@ -103,8 +103,13 @@ background: var(--highlight-background); border: solid 1px hsl(142, 25%, 42%); } + .recharts-legend-item { + user-select: none; + } .recharts-legend-item.inactive .recharts-legend-item-text { + text-decoration: line-through; + text-decoration-color: #666; opacity: 0.8; } .chart-prefix {