diff --git a/.env b/.env
index 82490c4..ba0c9ea 100644
--- a/.env
+++ b/.env
@@ -1,7 +1,7 @@
-REACT_APP_API_URL=https://api.harmonydata.ac.uk
+REACT_APP_API_URL=http://localhost:8000
REACT_APP_API_EXAMPLES=$REACT_APP_API_URL/text/examples
REACT_APP_API_PARSE=$REACT_APP_API_URL/text/parse
REACT_APP_API_MATCH=$REACT_APP_API_URL/text/match
REACT_APP_API_VERSION=$REACT_APP_API_URL/info/version
REACT_APP_API_MODELS=$REACT_APP_API_URL/info/list-models
-REACT_APP_ABSOLUTE_URL_PREFIX=https://harmonydata.github.io
+REACT_APP_ABSOLUTE_URL_PREFIX=http://localhost:8000
diff --git a/.env.development b/.env.development
index c017fb8..ba0c9ea 100644
--- a/.env.development
+++ b/.env.development
@@ -1,7 +1,7 @@
-REACT_APP_API_URL=https://harmonystagingtmp.azurewebsites.net/
+REACT_APP_API_URL=http://localhost:8000
REACT_APP_API_EXAMPLES=$REACT_APP_API_URL/text/examples
REACT_APP_API_PARSE=$REACT_APP_API_URL/text/parse
REACT_APP_API_MATCH=$REACT_APP_API_URL/text/match
REACT_APP_API_VERSION=$REACT_APP_API_URL/info/version
REACT_APP_API_MODELS=$REACT_APP_API_URL/info/list-models
-REACT_APP_ABSOLUTE_URL_PREFIX=https://harmonydata.github.io
+REACT_APP_ABSOLUTE_URL_PREFIX=http://localhost:8000
diff --git a/coverage/clover.xml b/coverage/clover.xml
new file mode 100644
index 0000000..7fe5960
--- /dev/null
+++ b/coverage/clover.xml
@@ -0,0 +1,193 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import React, { useState, useEffect, useMemo, useCallback } from "react";
+import {
+ Container,
+ Box,
+ Slide,
+ useMediaQuery,
+ Link,
+ Typography,
+ Rating,
+} from "@mui/material";
+import { HashRouter as Router, Switch, Route } from "react-router-dom";
+import Upload from "./Upload";
+import Results from "./Results";
+import Login from "./Login";
+import CssBaseline from "@mui/material/CssBaseline";
+import { getDesignTokens, getThemedComponents } from "../conf/theme.ts";
+import {
+ createTheme,
+ ThemeProvider,
+ responsiveFontSizes,
+} from "@mui/material/styles";
+import { simplifyApi } from "../utilities/simplifyApi";
+import HarmonyAppBar from "./AppBar";
+import pattern from "../img/pattern.svg";
+import logoWithText from "../img/Logo-04-min.svg";
+import ResultsOptions from "./ResultsOptions";
+import { deepmerge } from "@mui/utils";
+import { ColorModeContext } from "../contexts/ColorModeContext";
+import { useData } from "../contexts/DataContext";
+import { utils as XLSXutils, writeFile as XLSXwriteFile } from "xlsx";
+import ReactGA from "react-ga4";
+import CookieConsent, { getCookieConsentValue } from "react-cookie-consent";
+import { ToastContainer, toast } from "react-toastify";
+import MakeMeJSON from "./MakeMeJSON.js";
+import "react-toastify/dist/ReactToastify.css";
+import YouTube from "react-youtube";
+import "../css/youtube.css";
+import { useHistory } from "react-router-dom";
+import HarmonyPDFExport from './HarmonyPDFExport';
+
+function App() {
+ const history = useHistory();
+ const [fullscreen, setFullscreen] = useState(false);
+ const [existingInstruments, setExistingInstruments] = useState([]);
+ const [apiData, setApiData] = useState({});
+ const [resultsOptions, setResultsOptions] = useState({
+ threshold: [70, 100],
+ searchTerm: "",
+ intraInstrument: false,
+ });
+ const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
+ const [mode, setMode] = useState();
+ const {
+ storeHarmonisation,
+ reportRating,
+ exampleInstruments,
+ match,
+ currentModel,
+ } = useData();
+ const [ratingValue, setRatingValue] = useState();
+ const [computedMatches, setComputedMatches] = useState();
+ const [fileInfos, setFileInfos] = useState();
+ useEffect(() => {
+ setMode(prefersDarkMode ? "dark" : "light");
+ }, [prefersDarkMode]);
+
+ useEffect(() => {
+ const handleBeforeUnload = (event) => {
+ // stash the current fileInfos to sessionStorage so they can be retreived in the case of handling an import link
+ if (fileInfos.length)
+ sessionStorage["harmonyStashed"] = JSON.stringify(fileInfos);
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ }, [fileInfos]);
+
+ useEffect(() => {
+ if (
+ sessionStorage["harmonyStashed"] &&
+ sessionStorage["harmonyStashed"] !== "undefined"
+ )
+ setFileInfos(JSON.parse(sessionStorage["harmonyStashed"]));
+ }, []);
+
+ useEffect(() => {
+ //default to intraInstrument ON in the case of just one instument in the model
+ if (
+ fileInfos &&
+ fileInfos.length === 1 &&
+ resultsOptions.intraInstrument === false
+ ) {
+ let newResultsOptions = { ...resultsOptions };
+ newResultsOptions.intraInstrument = true;
+ newResultsOptions.intraInstrumentPreviousState =
+ resultsOptions.intraInstrument;
+ setResultsOptions(newResultsOptions);
+ }
+
+ // If there is now more than 1 switch it back to what it was before we forced it.
+ if (
+ fileInfos &&
+ fileInfos.length > 1 &&
+ typeof resultsOptions.intraInstrumentPreviousState == "boolean"
+ ) {
+ let newResultsOptions = { ...resultsOptions };
+ newResultsOptions.intraInstrument =
+ newResultsOptions.intraInstrumentPreviousState;
+ delete newResultsOptions.intraInstrumentPreviousState;
+ setResultsOptions(newResultsOptions);
+ }
+ }, [fileInfos, resultsOptions]);
+
+ useEffect(() => {
+ if (getCookieConsentValue("harmonyCookieConsent")) {
+ ReactGA.initialize("G-S79J6E39ZP");
+ console.log("GA enabled");
+ }
+ exampleInstruments()
+ .then((data) => {
+ setExistingInstruments(data);
+ console.log(data);
+ })
+ .catch((e) => {
+ console.log(e);
+ });
+ }, [exampleInstruments]);
+
+ const colorMode = useMemo(
+ () => ({
+ toggleColorMode: () => {
+ setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
+ },
+ }),
+ []
+ );
+
+ const getQuestion = (qidx) => {
+ return apiData.instruments
+ .map((i) => {
+ return i.questions;
+ })
+ .flat()
+ .filter((q) => {
+ return q.question_index === qidx;
+ })[0];
+ };
+
+ const executeMatch = useCallback(
+ (forceModel) => {
+ if (fileInfos)
+ return match(fileInfos, forceModel).then((data) => {
+ let simpleApi = simplifyApi(data, fileInfos);
+ setApiData(simpleApi);
+ });
+ },
+ [history, fileInfos]
+ );
+
+ useEffect(() => {
+ if (window.location.href.includes("/model")) {
+ executeMatch(currentModel);
+ }
+ }, [currentModel, executeMatch]);
+
+ const makePublicShareLink = () => {
+ let h = {};
+ h.apiData = apiData;
+ h.resultsOptions = resultsOptions;
+ h.public = true;
+ return new Promise((resolve, reject) => {
+ storeHarmonisation(h)
+ .then((doc) => {
+ console.log(doc);
+ resolve(window.location.origin + "/app/#/model/" + doc.id);
+ })
+ .catch((e) => {
+ console.log(e);
+ reject("Could not create share link");
+ });
+ });
+ };
+
+ const ratingToast = () => {
+ if (
+ !document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("harmonyHasRated"))
+ ) {
+ toast(
+ <Box>
+ <Typography component="legend">Are you enjoying Harmony?</Typography>
+ <Box>
+ <Rating
+ name="simple-controlled"
+ value={ratingValue}
+ onChange={(event, newValue) => {
+ console.log(newValue);
+ setRatingValue(newValue);
+ reportRating(newValue);
+ document.cookie =
+ "harmonyHasRated=true; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=None; Secure";
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Rating",
+ value: Number(newValue),
+ });
+ }}
+ />
+ </Box>
+ </Box>,
+ {
+ autoClose: false,
+ }
+ );
+ }
+ };
+
+ const saveToMyHarmony = () => {
+ setTimeout(ratingToast, 1000);
+ let h = {};
+ h.apiData = JSON.parse(JSON.stringify(apiData));
+ h.resultsOptions = resultsOptions;
+ h.public = false;
+ h.created = new Date();
+ return new Promise((resolve, reject) => {
+ storeHarmonisation(h)
+ .then((docRef) => {
+ resolve(window.location.origin + "/#/match/" + docRef);
+ })
+ .catch((e) => {
+ console.log(e);
+ reject("Could not create share link");
+ });
+ });
+ };
+
+ const downloadExcel = () => {
+ setTimeout(ratingToast, 1000);
+
+ const matchSheet = computedMatches
+ .reduce(function (a, cm, i) {
+ let q = getQuestion(cm.qi);
+ let mq = getQuestion(cm.mqi);
+ a.push({
+ instrument1: q.instrument.name,
+ question1_no: q.question_no,
+ question1_text: q.question_text,
+ question1_topics:
+ Array.isArray(q.topics_strengths) && q.topics_strengths.join(", "),
+ instrument2: mq.instrument.name,
+ question2_no: mq.question_no,
+ question2_text: mq.question_text,
+ question2_topics:
+ Array.isArray(mq.topics_strengths) &&
+ mq.topics_strengths.join(", "),
+ match: cm.match,
+ });
+ return a;
+ }, [])
+ .flat()
+ .sort((a, b) => {
+ if (Math.abs(a.match) < Math.abs(b.match)) {
+ return 1;
+ }
+ if (Math.abs(a.match) > Math.abs(b.match)) {
+ return -1;
+ }
+ return 0;
+ });
+ const allQs = apiData.instruments
+ .map((i) => {
+ return i.questions;
+ })
+ .flat()
+ .sort((a, b) => {
+ if (a.question_index > b.question_index) {
+ return 1;
+ }
+ if (a.question_index < b.question_index) {
+ return -1;
+ }
+ return 0;
+ });
+
+ const headers = allQs.map((q) => {
+ return q.instrument.name + " " + q.question_no;
+ });
+ const subheaders = allQs.map((q) => {
+ return q.question_text;
+ });
+
+ const matrixSheet = allQs.map((q, i) => {
+ return Array(i + 1).concat(q.matches);
+ });
+ matrixSheet.unshift(subheaders);
+ matrixSheet.unshift(headers);
+
+ const matches = XLSXutils.json_to_sheet(matchSheet);
+ const matrix = XLSXutils.aoa_to_sheet(matrixSheet);
+ const workbook = XLSXutils.book_new();
+ XLSXutils.book_append_sheet(workbook, matches, "Matches");
+ XLSXutils.book_append_sheet(workbook, matrix, "Matrix");
+ XLSXwriteFile(workbook, "Harmony.xlsx");
+ };
+
+
+
+ const downloadPDF = async () => {
+ try {
+ const formattedMatches = computedMatches.map(match => ({
+ score: match.match,
+ question1: {
+ question_text: getQuestion(match.qi).question_text,
+ instrument_name: getQuestion(match.qi).instrument.name
+ },
+ question2: {
+ question_text: getQuestion(match.mqi).question_text,
+ instrument_name: getQuestion(match.mqi).instrument.name
+ }
+ }));
+
+ const pdfExport = new HarmonyPDFExport();
+ const pdfBlob = await pdfExport.generateReport({
+ matches: formattedMatches,
+ instruments: apiData.instruments,
+ threshold: resultsOptions.threshold[0],
+ selectedMatches: computedMatches
+ .filter(m => m.selected)
+ .map(m => m.id)
+ });
+
+ const url = URL.createObjectURL(pdfBlob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'harmony_matches.pdf';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ ReactGA?.event({
+ category: "Actions",
+ action: "Export PDF"
+ });
+
+ setTimeout(ratingToast, 1000);
+ } catch (error) {
+ console.error('Error generating PDF:', error);
+ toast.error('Failed to generate PDF report');
+ }
+ };
+
+
+
+
+
+
+
+
+
+ let theme = useMemo(
+ () =>
+ createTheme(deepmerge(getDesignTokens(mode), getThemedComponents(mode))),
+ [mode]
+ );
+
+ theme = responsiveFontSizes(theme);
+ return (
+ <ColorModeContext.Provider value={colorMode}>
+ <ThemeProvider theme={theme}>
+ <CssBaseline />
+ <Container
+ disableGutters={true}
+ //
+ sx={{
+ display: { lg: "flex", md: "block" },
+ flexDirection: useMediaQuery(theme.breakpoints.down("lg"))
+ ? "column"
+ : "row",
+ justifyContent: "center",
+ alignItems: "center",
+ height: "100vh",
+ width: "100%",
+ maxWidth: "100%!important",
+ }}
+ >
+ <ToastContainer theme={theme.palette.mode} />
+ <Router>
+ {/* Side bar for wide screens - narrow screens at top of screen and only on upload page*/}
+ <Box
+ sx={{
+ display: "flex",
+ boxSizing: "border-box",
+ width: { lg: "35%", md: "100%" },
+ minWidth: 300,
+ top: 0,
+ marginLeft: 0,
+ marginRight: "auto",
+ height: { lg: "100%", md: "unset" },
+ background: "linear-gradient(-135deg,#0de5b2, #2b45ed)",
+ backgroundImage: `linear-gradient(-135deg,#0de5b2DD, #2b45edAA), url(${pattern}), linear-gradient(-135deg,#0de5b2, #2b45ed)`,
+ backgroundSize: "cover",
+ backgroundRepeat: "no-repeat",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ padding: "2rem",
+ color: "white",
+ }}
+ >
+ <Link href="#" sx={{ width: "80%", maxWidth: 700, mx: "auto" }}>
+ <img src={logoWithText} alt="Harmony Logo" />
+ </Link>
+
+ <Switch>
+ <Route path="/model/:stateHash?">
+ <ResultsOptions
+ resultsOptions={resultsOptions}
+ setResultsOptions={setResultsOptions}
+ makePublicShareLink={makePublicShareLink}
+ saveToMyHarmony={saveToMyHarmony}
+ downloadExcel={downloadExcel}
+ downloadPDF={downloadPDF}
+ toaster={toast}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="*">
+ <div>
+ <h1 style={{ color: "white" }}>
+ Harmonise questionnaire items
+ </h1>
+ <p>
+ Harmony is an AI tool which can read questionnaires and
+ find questions with similar meanings, such as{" "}
+ <i>anxiety</i> vs <i>I feel anxious</i>.
+ </p>
+ <p>
+ Psychologists sometimes need to combine survey results,
+ especially when surveys have been run by different
+ organisations or in different countries.
+ </p>
+ <p>
+ Try two example PDFs:{" "}
+ <a
+ target="gad7-pdf"
+ style={{ color: "white" }}
+ href="https://adaa.org/sites/default/files/GAD-7_Anxiety-updated_0.pdf"
+ >
+ GAD-7 PDF
+ </a>{" "}
+ vs{" "}
+ <a
+ target="phq-pdf"
+ style={{ color: "white" }}
+ href="https://www.apa.org/depression-guideline/patient-health-questionnaire.pdf"
+ >
+ PHQ-9 PDF
+ </a>
+ .
+ </p>
+ <p>
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/frequently-asked-questions"
+ >
+ FAQs
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/privacy-policy"
+ >
+ Privacy policy
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/formatting-help/"
+ >
+ Help with formatting
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/troubleshooting-harmony/"
+ >
+ Troubleshooting
+ </a>
+ </p>
+ </div>
+ <YouTube
+ className={"youtubeContainer" + (fullscreen ? "Full" : "")}
+ iframeClassName="youtubeIframe"
+ videoId="cEZppTBj1NI"
+ onPlay={() => setFullscreen(true)}
+ onPause={() => setFullscreen(false)}
+ />
+ </Route>
+ </Switch>
+ </Box>
+ <HarmonyAppBar></HarmonyAppBar>
+ <Slide in={true} direction="up">
+ <Box
+ sx={{
+ width: { lg: "65%", md: "100%" },
+ maxHeight: { lg: "100%" },
+ paddingTop: { lg: "4rem" },
+ overflow: "auto",
+ padding: useMediaQuery(theme.breakpoints.only("xs"))
+ ? "0.5rem"
+ : "2rem",
+ }}
+ >
+ <Switch>
+ {/* <Route path="/signup" component={Signup} /> */}
+ {/* <Route path="/forgot-password" component={ForgotPassword} /> */}
+ <Route path="/login">
+ <Login />
+ </Route>
+ <Route path="/model/:stateHash?">
+ <Results
+ fileInfos={fileInfos}
+ apiData={apiData}
+ setApiData={setApiData}
+ setResultsOptions={setResultsOptions}
+ resultsOptions={resultsOptions}
+ toaster={toast}
+ computedMatches={computedMatches}
+ setComputedMatches={setComputedMatches}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="/makeMeJSON">
+ <MakeMeJSON
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ setApiData={setApiData}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="/import/:importId">
+ <Upload
+ executeMatch={executeMatch}
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="*">
+ <Upload
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ executeMatch={executeMatch}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ </Switch>
+ </Box>
+ </Slide>
+ </Router>
+
+ <CookieConsent
+ acceptOnScroll={false}
+ location="bottom"
+ buttonText="That's fine"
+ cookieName="harmonyCookieConsent"
+ style={{ background: "#2B373B" }}
+ buttonStyle={{ color: "#4e503b", fontSize: "13px" }}
+ expires={150}
+ onAccept={() => {
+ ReactGA.initialize("G-S79J6E39ZP");
+ console.log("GA enabled");
+ }}
+ >
+ This website uses analytics cookies to allow us to improve the user
+ experience.{" "}
+ </CookieConsent>
+ </Container>
+ </ThemeProvider>
+ </ColorModeContext.Provider>
+ );
+}
+
+export default App;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x +3x + +3x +3x +3x + + +3x +3x + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x +2x +2x + + + + +2x + +1x +1x + + + + + + + + + + + + + + + + + + + + | import React, { useEffect, useMemo, useState } from "react";
+import {
+ Divider,
+ Card,
+ Slider,
+ Switch,
+ Typography,
+ Stack,
+ Button,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import { ReactComponent as xlsxSVG } from "../img/file-excel-solid.svg";
+import { ReactComponent as pdfSVG } from "../img/pdf.svg";
+import DropdownShareButton from "./DropdownShareButton";
+import SvgIcon from "@mui/material/SvgIcon";
+import { useAuth } from "../contexts/AuthContext";
+import { useDebounce } from "react-use-custom-hooks";
+import PopperHelp from "./PopperHelp";
+
+export default function ResultsOptions({
+ resultsOptions,
+ setResultsOptions,
+ makePublicShareLink,
+ saveToMyHarmony,
+ downloadExcel,
+ downloadPDF,
+ ReactGA,
+ toaster,
+}) {
+ const [threshold, setThreshold] = useState(resultsOptions.threshold);
+ const { currentUser } = useAuth();
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ useEffect(() => {
+ setThreshold(resultsOptions.threshold);
+ setSearchTerm(resultsOptions.searchTerm);
+ }, [resultsOptions]);
+
+ useMemo(() => {
+ Iif (debouncedSearchTerm !== resultsOptions.searchTerm) {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.searchTerm = debouncedSearchTerm;
+ setResultsOptions(thisOptions);
+ }
+ }, [debouncedSearchTerm, resultsOptions, setResultsOptions]);
+
+ return (
+ <Card
+ sx={{
+ display: "flex",
+ flexDirection: "column",
+ width: { xs: "100%", sm: "75%" },
+ margin: "auto",
+ padding: "1rem",
+ }}
+ >
+ <h2 style={{ marginTop: 0 }}>Options</h2>
+ <Stack>
+ <div>
+ <Typography id="Threshold">Match Threshold</Typography>
+ </div>
+ <Slider
+ value={threshold}
+ min={0}
+ valueLabelDisplay="auto"
+ onChange={(e, value) => {
+ setThreshold(value);
+ }}
+ onChangeCommitted={(e, value) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.threshold = value;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <TextField
+ sx={{ mt: 1, mb: 1 }}
+ id="outlined-basic"
+ label="Search"
+ autoComplete="off"
+ inputProps={{
+ autoComplete: "off",
+ }}
+ onChange={(e) => {
+ setSearchTerm(e.target.value);
+ }}
+ value={searchTerm}
+ variant="outlined"
+ InputProps={{
+ endAdornment: (
+ <InputAdornment position="end">
+ <PopperHelp>
+ <Typography>
+ This supports Lucene-like queries. So you can use wildcards,
+ logical operators, parentheses, and negation to create
+ precise and complex searches. You can also search within
+ specific data fields (instrument, question, or topic) e.g.
+ <br />
+ <br />
+ <b>instrument:RCAD and instrument:GAD</b>
+ <br />
+ <br />
+ which will show matches in your results between these two
+ instruments only.
+ </Typography>
+ </PopperHelp>
+ </InputAdornment>
+ ),
+ }}
+ />
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <Typography id="withinInstruments">
+ Show within-instrument matches
+ </Typography>
+ <Switch
+ checked={resultsOptions.intraInstrument}
+ onChange={(event) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.intraInstrument = event.target.checked;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ </Stack>
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <Typography id="withinInstruments">Just selected matches</Typography>
+ <Switch
+ checked={resultsOptions.onlySelected}
+ onChange={(event) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.onlySelected = event.target.checked;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ </Stack>
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-around",
+ }}
+ >
+ {currentUser && (
+ <DropdownShareButton
+ getShareLink={makePublicShareLink}
+ ReactGA={ReactGA}
+ />
+ )}
+ <Button
+ variant="contained"
+ onClick={() => {
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Export Matches",
+ });
+ downloadExcel();
+ }}
+ >
+ <SvgIcon component={xlsxSVG} inheritViewBox />
+ <Typography> Excel Export</Typography>
+ </Button>
+
+ <Button
+ variant="contained"
+ onClick={async () => {
+ console.log('PDF Export Button Clicked');
+ try {
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Export PDF",
+ });
+ await downloadPDF(resultsOptions.threshold);
+ } catch (error) {
+ console.error('PDF Export Failed:', error);
+ toaster.error('Failed to generate PDF report');
+ }
+ }}
+ >
+ <SvgIcon component={pdfSVG} inheritViewBox />
+ <Typography>PDF Export</Typography>
+ </Button>
+
+
+
+
+
+
+
+
+ </Stack>
+ </Stack>
+ </Card>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import React, { useState, useEffect, useMemo, useCallback } from "react";
+import {
+ Container,
+ Box,
+ Slide,
+ useMediaQuery,
+ Link,
+ Typography,
+ Rating,
+} from "@mui/material";
+import { HashRouter as Router, Switch, Route } from "react-router-dom";
+import Upload from "./Upload";
+import Results from "./Results";
+import Login from "./Login";
+import CssBaseline from "@mui/material/CssBaseline";
+import { getDesignTokens, getThemedComponents } from "../conf/theme.ts";
+import {
+ createTheme,
+ ThemeProvider,
+ responsiveFontSizes,
+} from "@mui/material/styles";
+import { simplifyApi } from "../utilities/simplifyApi";
+import HarmonyAppBar from "./AppBar";
+import pattern from "../img/pattern.svg";
+import logoWithText from "../img/Logo-04-min.svg";
+import ResultsOptions from "./ResultsOptions";
+import { deepmerge } from "@mui/utils";
+import { ColorModeContext } from "../contexts/ColorModeContext";
+import { useData } from "../contexts/DataContext";
+import { utils as XLSXutils, writeFile as XLSXwriteFile } from "xlsx";
+import ReactGA from "react-ga4";
+import CookieConsent, { getCookieConsentValue } from "react-cookie-consent";
+import { ToastContainer, toast } from "react-toastify";
+import MakeMeJSON from "./MakeMeJSON.js";
+import "react-toastify/dist/ReactToastify.css";
+import YouTube from "react-youtube";
+import "../css/youtube.css";
+import { useHistory } from "react-router-dom";
+import HarmonyPDFExport from './HarmonyPDFExport';
+
+function App() {
+ const history = useHistory();
+ const [fullscreen, setFullscreen] = useState(false);
+ const [existingInstruments, setExistingInstruments] = useState([]);
+ const [apiData, setApiData] = useState({});
+ const [resultsOptions, setResultsOptions] = useState({
+ threshold: [70, 100],
+ searchTerm: "",
+ intraInstrument: false,
+ });
+ const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
+ const [mode, setMode] = useState();
+ const {
+ storeHarmonisation,
+ reportRating,
+ exampleInstruments,
+ match,
+ currentModel,
+ } = useData();
+ const [ratingValue, setRatingValue] = useState();
+ const [computedMatches, setComputedMatches] = useState();
+ const [fileInfos, setFileInfos] = useState();
+ useEffect(() => {
+ setMode(prefersDarkMode ? "dark" : "light");
+ }, [prefersDarkMode]);
+
+ useEffect(() => {
+ const handleBeforeUnload = (event) => {
+ // stash the current fileInfos to sessionStorage so they can be retreived in the case of handling an import link
+ if (fileInfos.length)
+ sessionStorage["harmonyStashed"] = JSON.stringify(fileInfos);
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ }, [fileInfos]);
+
+ useEffect(() => {
+ if (
+ sessionStorage["harmonyStashed"] &&
+ sessionStorage["harmonyStashed"] !== "undefined"
+ )
+ setFileInfos(JSON.parse(sessionStorage["harmonyStashed"]));
+ }, []);
+
+ useEffect(() => {
+ //default to intraInstrument ON in the case of just one instument in the model
+ if (
+ fileInfos &&
+ fileInfos.length === 1 &&
+ resultsOptions.intraInstrument === false
+ ) {
+ let newResultsOptions = { ...resultsOptions };
+ newResultsOptions.intraInstrument = true;
+ newResultsOptions.intraInstrumentPreviousState =
+ resultsOptions.intraInstrument;
+ setResultsOptions(newResultsOptions);
+ }
+
+ // If there is now more than 1 switch it back to what it was before we forced it.
+ if (
+ fileInfos &&
+ fileInfos.length > 1 &&
+ typeof resultsOptions.intraInstrumentPreviousState == "boolean"
+ ) {
+ let newResultsOptions = { ...resultsOptions };
+ newResultsOptions.intraInstrument =
+ newResultsOptions.intraInstrumentPreviousState;
+ delete newResultsOptions.intraInstrumentPreviousState;
+ setResultsOptions(newResultsOptions);
+ }
+ }, [fileInfos, resultsOptions]);
+
+ useEffect(() => {
+ if (getCookieConsentValue("harmonyCookieConsent")) {
+ ReactGA.initialize("G-S79J6E39ZP");
+ console.log("GA enabled");
+ }
+ exampleInstruments()
+ .then((data) => {
+ setExistingInstruments(data);
+ console.log(data);
+ })
+ .catch((e) => {
+ console.log(e);
+ });
+ }, [exampleInstruments]);
+
+ const colorMode = useMemo(
+ () => ({
+ toggleColorMode: () => {
+ setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
+ },
+ }),
+ []
+ );
+
+ const getQuestion = (qidx) => {
+ return apiData.instruments
+ .map((i) => {
+ return i.questions;
+ })
+ .flat()
+ .filter((q) => {
+ return q.question_index === qidx;
+ })[0];
+ };
+
+ const executeMatch = useCallback(
+ (forceModel) => {
+ if (fileInfos)
+ return match(fileInfos, forceModel).then((data) => {
+ let simpleApi = simplifyApi(data, fileInfos);
+ setApiData(simpleApi);
+ });
+ },
+ [history, fileInfos]
+ );
+
+ useEffect(() => {
+ if (window.location.href.includes("/model")) {
+ executeMatch(currentModel);
+ }
+ }, [currentModel, executeMatch]);
+
+ const makePublicShareLink = () => {
+ let h = {};
+ h.apiData = apiData;
+ h.resultsOptions = resultsOptions;
+ h.public = true;
+ return new Promise((resolve, reject) => {
+ storeHarmonisation(h)
+ .then((doc) => {
+ console.log(doc);
+ resolve(window.location.origin + "/app/#/model/" + doc.id);
+ })
+ .catch((e) => {
+ console.log(e);
+ reject("Could not create share link");
+ });
+ });
+ };
+
+ const ratingToast = () => {
+ if (
+ !document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("harmonyHasRated"))
+ ) {
+ toast(
+ <Box>
+ <Typography component="legend">Are you enjoying Harmony?</Typography>
+ <Box>
+ <Rating
+ name="simple-controlled"
+ value={ratingValue}
+ onChange={(event, newValue) => {
+ console.log(newValue);
+ setRatingValue(newValue);
+ reportRating(newValue);
+ document.cookie =
+ "harmonyHasRated=true; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=None; Secure";
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Rating",
+ value: Number(newValue),
+ });
+ }}
+ />
+ </Box>
+ </Box>,
+ {
+ autoClose: false,
+ }
+ );
+ }
+ };
+
+ const saveToMyHarmony = () => {
+ setTimeout(ratingToast, 1000);
+ let h = {};
+ h.apiData = JSON.parse(JSON.stringify(apiData));
+ h.resultsOptions = resultsOptions;
+ h.public = false;
+ h.created = new Date();
+ return new Promise((resolve, reject) => {
+ storeHarmonisation(h)
+ .then((docRef) => {
+ resolve(window.location.origin + "/#/match/" + docRef);
+ })
+ .catch((e) => {
+ console.log(e);
+ reject("Could not create share link");
+ });
+ });
+ };
+
+ const downloadExcel = () => {
+ setTimeout(ratingToast, 1000);
+
+ const matchSheet = computedMatches
+ .reduce(function (a, cm, i) {
+ let q = getQuestion(cm.qi);
+ let mq = getQuestion(cm.mqi);
+ a.push({
+ instrument1: q.instrument.name,
+ question1_no: q.question_no,
+ question1_text: q.question_text,
+ question1_topics:
+ Array.isArray(q.topics_strengths) && q.topics_strengths.join(", "),
+ instrument2: mq.instrument.name,
+ question2_no: mq.question_no,
+ question2_text: mq.question_text,
+ question2_topics:
+ Array.isArray(mq.topics_strengths) &&
+ mq.topics_strengths.join(", "),
+ match: cm.match,
+ });
+ return a;
+ }, [])
+ .flat()
+ .sort((a, b) => {
+ if (Math.abs(a.match) < Math.abs(b.match)) {
+ return 1;
+ }
+ if (Math.abs(a.match) > Math.abs(b.match)) {
+ return -1;
+ }
+ return 0;
+ });
+ const allQs = apiData.instruments
+ .map((i) => {
+ return i.questions;
+ })
+ .flat()
+ .sort((a, b) => {
+ if (a.question_index > b.question_index) {
+ return 1;
+ }
+ if (a.question_index < b.question_index) {
+ return -1;
+ }
+ return 0;
+ });
+
+ const headers = allQs.map((q) => {
+ return q.instrument.name + " " + q.question_no;
+ });
+ const subheaders = allQs.map((q) => {
+ return q.question_text;
+ });
+
+ const matrixSheet = allQs.map((q, i) => {
+ return Array(i + 1).concat(q.matches);
+ });
+ matrixSheet.unshift(subheaders);
+ matrixSheet.unshift(headers);
+
+ const matches = XLSXutils.json_to_sheet(matchSheet);
+ const matrix = XLSXutils.aoa_to_sheet(matrixSheet);
+ const workbook = XLSXutils.book_new();
+ XLSXutils.book_append_sheet(workbook, matches, "Matches");
+ XLSXutils.book_append_sheet(workbook, matrix, "Matrix");
+ XLSXwriteFile(workbook, "Harmony.xlsx");
+ };
+
+
+
+ const downloadPDF = async () => {
+ try {
+ const formattedMatches = computedMatches.map(match => ({
+ score: match.match,
+ question1: {
+ question_text: getQuestion(match.qi).question_text,
+ instrument_name: getQuestion(match.qi).instrument.name
+ },
+ question2: {
+ question_text: getQuestion(match.mqi).question_text,
+ instrument_name: getQuestion(match.mqi).instrument.name
+ }
+ }));
+
+ const pdfExport = new HarmonyPDFExport();
+ const pdfBlob = await pdfExport.generateReport({
+ matches: formattedMatches,
+ instruments: apiData.instruments,
+ threshold: resultsOptions.threshold[0],
+ selectedMatches: computedMatches
+ .filter(m => m.selected)
+ .map(m => m.id)
+ });
+
+ const url = URL.createObjectURL(pdfBlob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'harmony_matches.pdf';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ ReactGA?.event({
+ category: "Actions",
+ action: "Export PDF"
+ });
+
+ setTimeout(ratingToast, 1000);
+ } catch (error) {
+ console.error('Error generating PDF:', error);
+ toast.error('Failed to generate PDF report');
+ }
+ };
+
+
+
+
+
+
+
+
+
+ let theme = useMemo(
+ () =>
+ createTheme(deepmerge(getDesignTokens(mode), getThemedComponents(mode))),
+ [mode]
+ );
+
+ theme = responsiveFontSizes(theme);
+ return (
+ <ColorModeContext.Provider value={colorMode}>
+ <ThemeProvider theme={theme}>
+ <CssBaseline />
+ <Container
+ disableGutters={true}
+ //
+ sx={{
+ display: { lg: "flex", md: "block" },
+ flexDirection: useMediaQuery(theme.breakpoints.down("lg"))
+ ? "column"
+ : "row",
+ justifyContent: "center",
+ alignItems: "center",
+ height: "100vh",
+ width: "100%",
+ maxWidth: "100%!important",
+ }}
+ >
+ <ToastContainer theme={theme.palette.mode} />
+ <Router>
+ {/* Side bar for wide screens - narrow screens at top of screen and only on upload page*/}
+ <Box
+ sx={{
+ display: "flex",
+ boxSizing: "border-box",
+ width: { lg: "35%", md: "100%" },
+ minWidth: 300,
+ top: 0,
+ marginLeft: 0,
+ marginRight: "auto",
+ height: { lg: "100%", md: "unset" },
+ background: "linear-gradient(-135deg,#0de5b2, #2b45ed)",
+ backgroundImage: `linear-gradient(-135deg,#0de5b2DD, #2b45edAA), url(${pattern}), linear-gradient(-135deg,#0de5b2, #2b45ed)`,
+ backgroundSize: "cover",
+ backgroundRepeat: "no-repeat",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ padding: "2rem",
+ color: "white",
+ }}
+ >
+ <Link href="#" sx={{ width: "80%", maxWidth: 700, mx: "auto" }}>
+ <img src={logoWithText} alt="Harmony Logo" />
+ </Link>
+
+ <Switch>
+ <Route path="/model/:stateHash?">
+ <ResultsOptions
+ resultsOptions={resultsOptions}
+ setResultsOptions={setResultsOptions}
+ makePublicShareLink={makePublicShareLink}
+ saveToMyHarmony={saveToMyHarmony}
+ downloadExcel={downloadExcel}
+ downloadPDF={downloadPDF}
+ toaster={toast}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="*">
+ <div>
+ <h1 style={{ color: "white" }}>
+ Harmonise questionnaire items
+ </h1>
+ <p>
+ Harmony is an AI tool which can read questionnaires and
+ find questions with similar meanings, such as{" "}
+ <i>anxiety</i> vs <i>I feel anxious</i>.
+ </p>
+ <p>
+ Psychologists sometimes need to combine survey results,
+ especially when surveys have been run by different
+ organisations or in different countries.
+ </p>
+ <p>
+ Try two example PDFs:{" "}
+ <a
+ target="gad7-pdf"
+ style={{ color: "white" }}
+ href="https://adaa.org/sites/default/files/GAD-7_Anxiety-updated_0.pdf"
+ >
+ GAD-7 PDF
+ </a>{" "}
+ vs{" "}
+ <a
+ target="phq-pdf"
+ style={{ color: "white" }}
+ href="https://www.apa.org/depression-guideline/patient-health-questionnaire.pdf"
+ >
+ PHQ-9 PDF
+ </a>
+ .
+ </p>
+ <p>
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/frequently-asked-questions"
+ >
+ FAQs
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/privacy-policy"
+ >
+ Privacy policy
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/formatting-help/"
+ >
+ Help with formatting
+ </a>{" "}
+ -{" "}
+ <a
+ style={{ color: "white" }}
+ href="https://harmonydata.ac.uk/troubleshooting-harmony/"
+ >
+ Troubleshooting
+ </a>
+ </p>
+ </div>
+ <YouTube
+ className={"youtubeContainer" + (fullscreen ? "Full" : "")}
+ iframeClassName="youtubeIframe"
+ videoId="cEZppTBj1NI"
+ onPlay={() => setFullscreen(true)}
+ onPause={() => setFullscreen(false)}
+ />
+ </Route>
+ </Switch>
+ </Box>
+ <HarmonyAppBar></HarmonyAppBar>
+ <Slide in={true} direction="up">
+ <Box
+ sx={{
+ width: { lg: "65%", md: "100%" },
+ maxHeight: { lg: "100%" },
+ paddingTop: { lg: "4rem" },
+ overflow: "auto",
+ padding: useMediaQuery(theme.breakpoints.only("xs"))
+ ? "0.5rem"
+ : "2rem",
+ }}
+ >
+ <Switch>
+ {/* <Route path="/signup" component={Signup} /> */}
+ {/* <Route path="/forgot-password" component={ForgotPassword} /> */}
+ <Route path="/login">
+ <Login />
+ </Route>
+ <Route path="/model/:stateHash?">
+ <Results
+ fileInfos={fileInfos}
+ apiData={apiData}
+ setApiData={setApiData}
+ setResultsOptions={setResultsOptions}
+ resultsOptions={resultsOptions}
+ toaster={toast}
+ computedMatches={computedMatches}
+ setComputedMatches={setComputedMatches}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="/makeMeJSON">
+ <MakeMeJSON
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ setApiData={setApiData}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="/import/:importId">
+ <Upload
+ executeMatch={executeMatch}
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ <Route path="*">
+ <Upload
+ appFileInfos={fileInfos}
+ setAppFileInfos={setFileInfos}
+ executeMatch={executeMatch}
+ existingInstruments={existingInstruments}
+ ReactGA={ReactGA}
+ />
+ </Route>
+ </Switch>
+ </Box>
+ </Slide>
+ </Router>
+
+ <CookieConsent
+ acceptOnScroll={false}
+ location="bottom"
+ buttonText="That's fine"
+ cookieName="harmonyCookieConsent"
+ style={{ background: "#2B373B" }}
+ buttonStyle={{ color: "#4e503b", fontSize: "13px" }}
+ expires={150}
+ onAccept={() => {
+ ReactGA.initialize("G-S79J6E39ZP");
+ console.log("GA enabled");
+ }}
+ >
+ This website uses analytics cookies to allow us to improve the user
+ experience.{" "}
+ </CookieConsent>
+ </Container>
+ </ThemeProvider>
+ </ColorModeContext.Provider>
+ );
+}
+
+export default App;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x +3x + +3x +3x +3x + + +3x +3x + + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x +2x +2x + + + + +2x + +1x +1x + + + + + + + + + + + + + + + + + + + + | import React, { useEffect, useMemo, useState } from "react";
+import {
+ Divider,
+ Card,
+ Slider,
+ Switch,
+ Typography,
+ Stack,
+ Button,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import { ReactComponent as xlsxSVG } from "../img/file-excel-solid.svg";
+import { ReactComponent as pdfSVG } from "../img/pdf.svg";
+import DropdownShareButton from "./DropdownShareButton";
+import SvgIcon from "@mui/material/SvgIcon";
+import { useAuth } from "../contexts/AuthContext";
+import { useDebounce } from "react-use-custom-hooks";
+import PopperHelp from "./PopperHelp";
+
+export default function ResultsOptions({
+ resultsOptions,
+ setResultsOptions,
+ makePublicShareLink,
+ saveToMyHarmony,
+ downloadExcel,
+ downloadPDF,
+ ReactGA,
+ toaster,
+}) {
+ const [threshold, setThreshold] = useState(resultsOptions.threshold);
+ const { currentUser } = useAuth();
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ useEffect(() => {
+ setThreshold(resultsOptions.threshold);
+ setSearchTerm(resultsOptions.searchTerm);
+ }, [resultsOptions]);
+
+ useMemo(() => {
+ Iif (debouncedSearchTerm !== resultsOptions.searchTerm) {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.searchTerm = debouncedSearchTerm;
+ setResultsOptions(thisOptions);
+ }
+ }, [debouncedSearchTerm, resultsOptions, setResultsOptions]);
+
+ return (
+ <Card
+ sx={{
+ display: "flex",
+ flexDirection: "column",
+ width: { xs: "100%", sm: "75%" },
+ margin: "auto",
+ padding: "1rem",
+ }}
+ >
+ <h2 style={{ marginTop: 0 }}>Options</h2>
+ <Stack>
+ <div>
+ <Typography id="Threshold">Match Threshold</Typography>
+ </div>
+ <Slider
+ value={threshold}
+ min={0}
+ valueLabelDisplay="auto"
+ onChange={(e, value) => {
+ setThreshold(value);
+ }}
+ onChangeCommitted={(e, value) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.threshold = value;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <TextField
+ sx={{ mt: 1, mb: 1 }}
+ id="outlined-basic"
+ label="Search"
+ autoComplete="off"
+ inputProps={{
+ autoComplete: "off",
+ }}
+ onChange={(e) => {
+ setSearchTerm(e.target.value);
+ }}
+ value={searchTerm}
+ variant="outlined"
+ InputProps={{
+ endAdornment: (
+ <InputAdornment position="end">
+ <PopperHelp>
+ <Typography>
+ This supports Lucene-like queries. So you can use wildcards,
+ logical operators, parentheses, and negation to create
+ precise and complex searches. You can also search within
+ specific data fields (instrument, question, or topic) e.g.
+ <br />
+ <br />
+ <b>instrument:RCAD and instrument:GAD</b>
+ <br />
+ <br />
+ which will show matches in your results between these two
+ instruments only.
+ </Typography>
+ </PopperHelp>
+ </InputAdornment>
+ ),
+ }}
+ />
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <Typography id="withinInstruments">
+ Show within-instrument matches
+ </Typography>
+ <Switch
+ checked={resultsOptions.intraInstrument}
+ onChange={(event) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.intraInstrument = event.target.checked;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ </Stack>
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <Typography id="withinInstruments">Just selected matches</Typography>
+ <Switch
+ checked={resultsOptions.onlySelected}
+ onChange={(event) => {
+ let thisOptions = { ...resultsOptions };
+ thisOptions.onlySelected = event.target.checked;
+ setResultsOptions(thisOptions);
+ }}
+ />
+ </Stack>
+ <Divider sx={{ mt: 1, mb: 1 }} />
+ <Stack
+ direction="row"
+ sx={{
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-around",
+ }}
+ >
+ {currentUser && (
+ <DropdownShareButton
+ getShareLink={makePublicShareLink}
+ ReactGA={ReactGA}
+ />
+ )}
+ <Button
+ variant="contained"
+ onClick={() => {
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Export Matches",
+ });
+ downloadExcel();
+ }}
+ >
+ <SvgIcon component={xlsxSVG} inheritViewBox />
+ <Typography> Excel Export</Typography>
+ </Button>
+
+ <Button
+ variant="contained"
+ onClick={async () => {
+ console.log('PDF Export Button Clicked');
+ try {
+ ReactGA &&
+ ReactGA.event({
+ category: "Actions",
+ action: "Export PDF",
+ });
+ await downloadPDF(resultsOptions.threshold);
+ } catch (error) {
+ console.error('PDF Export Failed:', error);
+ toaster.error('Failed to generate PDF report');
+ }
+ }}
+ >
+ <SvgIcon component={pdfSVG} inheritViewBox />
+ <Typography>PDF Export</Typography>
+ </Button>
+
+
+
+
+
+
+
+
+ </Stack>
+ </Stack>
+ </Card>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| App.js | +
+
+ |
+ 0% | +0/151 | +0% | +0/48 | +0% | +0/46 | +0% | +0/149 | +
| ResultsOptions.js | +
+
+ |
+ 50% | +16/32 | +50% | +4/8 | +40% | +4/10 | +50% | +16/32 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| App.js | +
+
+ |
+ 0% | +0/151 | +0% | +0/48 | +0% | +0/46 | +0% | +0/149 | +
| ResultsOptions.js | +
+
+ |
+ 50% | +16/32 | +50% | +4/8 | +40% | +4/10 | +50% | +16/32 | +