From 89ac41a6e6a733027494a49d47b50eb4c7470777 Mon Sep 17 00:00:00 2001 From: Epagris Date: Mon, 9 Sep 2024 18:36:03 +0200 Subject: [PATCH] - report generation implemented - recursive filtering fixed - grouping added --- interface.php | 18 ++++- js/common.js | 15 ++++ js/gamemgr.js | 4 + js/result_analyzer.js | 126 +++++++++++++++++++++++++++++-- js/testground.js | 63 ++++++++-------- report.html | 20 +++++ result_analyzer.php | 21 +++++- style/quizmaster_area.css | 35 +++++++++ testground.php | 15 +++- testmgr.php | 151 ++++++++++++++++++++++++++++++++++++-- 10 files changed, 410 insertions(+), 58 deletions(-) create mode 100644 report.html diff --git a/interface.php b/interface.php index b6aa0de..68c34a9 100644 --- a/interface.php +++ b/interface.php @@ -112,9 +112,9 @@ switch ($action) { } // test-related queries -if ((($testid = trim($_REQUEST["testid"] ?: "")) !== "") && +if (isset($_REQUEST["testid"]) && (($testid = trim($_REQUEST["testid"])) !== "") && ((count($test_data = get_test($testid))) > 0) && - ($test_data["nickname"] === $nickname)) { + (($test_data["nickname"] === $nickname) || $is_quizmaster)) { // update the test if timed update_timed_tests([$test_data]); @@ -141,6 +141,9 @@ if ((($testid = trim($_REQUEST["testid"] ?: "")) !== "") && } } break; + } + + switch ($action) { case "save_answer": { $chidx = $_REQUEST["challenge_index"]; @@ -313,12 +316,21 @@ switch ($action) { { $gameid = trim($_REQUEST["gameid"] ?: ""); $filter = trim($_REQUEST["filter"] ?: ""); + $ordering = trim($_REQUEST["orderby"] ?: ""); if (($gameid !== "") && (is_user_contributor_to_game($gameid, $nickname) || $is_quizmaster)) { - $game_results = get_results_by_gameid($gameid, $filter); + $game_results = get_results_by_gameid($gameid, $filter, $ordering, true); $result = json_encode($game_results); } } break; + case "generate_detailed_stats": + { + $testids = json_decode(trim($_REQUEST["testids"] ?: "[]"), true); + $gameid = trim($_REQUEST["gameid"] ?: ""); + $stats = generate_detailed_stats($gameid, $testids); + $result = json_encode($stats); + } + break; } // quizmaster actions diff --git a/js/common.js b/js/common.js index 768b367..a00c4b6 100644 --- a/js/common.js +++ b/js/common.js @@ -30,4 +30,19 @@ function time_to_seconds(t) { } } return s; +} + +function preprocess_inserts(str) { + let code_delim = '`'; + let parts = str.split(code_delim); + let res = ""; + for (let i = 0; i < parts.length; i++) { + res += parts[i]; + if (i % 2 === 0) { + res += ""; + } else { + res += ""; + } + } + return res; } \ No newline at end of file diff --git a/js/gamemgr.js b/js/gamemgr.js index 44f2667..798a6e0 100644 --- a/js/gamemgr.js +++ b/js/gamemgr.js @@ -339,6 +339,10 @@ function list_results_by_game(game) { } } + test_summary_record.addEventListener("click", () => { + window.open(`testground.php?testid=${record["_id"]}&view_only=true`, "_blank"); + }); + test_group_box.appendChild(test_summary_record); n--; diff --git a/js/result_analyzer.js b/js/result_analyzer.js index b63695b..b34efab 100644 --- a/js/result_analyzer.js +++ b/js/result_analyzer.js @@ -1,4 +1,4 @@ -function create_cell(content) { +function create_cell(content = "") { let cell = document.createElement("td"); cell.innerHTML = content; return cell; @@ -7,8 +7,14 @@ function create_cell(content) { function fetch_results() { let filterF = document.getElementById("filter"); + let orderbyF = document.getElementById("orderby"); - let req = {action: "get_results_by_gameid", gameid: GAMEID, filter: filterF.value.trim()}; + let req = { + action: "get_results_by_gameid", + gameid: GAMEID, + filter: filterF.value.trim(), + orderby: orderbyF.value.trim() + }; request(req).then(resp => { let rd = document.getElementById("results_display"); @@ -28,8 +34,9 @@ function fetch_results() { // is the game concluded let concluded = record["state"] === "concluded"; - let percentage = "-"; - let timestamp = "-"; + let percentage = ""; + let start_timestamp = unix_time_to_human_readable(record["start_time"]); + let end_timestamp = ""; // replace some fields if game was concluded if (concluded) { @@ -39,16 +46,31 @@ function fetch_results() { percentage = `${r}%`; // finish timestamp - timestamp = unix_time_to_human_readable(record["end_time"]); + end_timestamp = unix_time_to_human_readable(record["end_time"]); } // create cells - let empty_cell = create_cell(""); + let selectChk = document.createElement("input"); + selectChk.type = "checkbox"; + selectChk.name = "game_select"; + selectChk.record = record; + + let selection_cell = create_cell(); + selection_cell.append(selectChk); + + let id_cell = create_cell(record["_id"]); + + let inspect_link = `🔎` + let inspect_cell = create_cell(inspect_link); let name_cell = create_cell(record.nickname) let percentage_cell = create_cell(percentage) - let timestamp_cell = create_cell(timestamp); + let start_timestamp_cell = create_cell(start_timestamp); + let end_timestamp_cell = create_cell(end_timestamp); - row.append(empty_cell, name_cell, percentage_cell, timestamp_cell); + row.append(selection_cell, id_cell, inspect_cell, name_cell, percentage_cell, start_timestamp_cell, end_timestamp_cell); + + // save record data into the row object + row.record = record; // append row rd.appendChild(row); @@ -56,3 +78,91 @@ function fetch_results() { }); } + +function generate_report() { + let testids = []; + let game_selectChks = document.getElementsByName("game_select"); + game_selectChks.forEach((chk) => { + if (chk.checked) { + testids.push(chk.record["_id"]); + } + }); + + let req = { + action: "generate_detailed_stats", + gameid: GAMEID, + testids: JSON.stringify(testids) + }; + + request(req).then((resp) => { + let stats = JSON.parse(resp); + let statsTab = window.open("report.html", "_blank"); + + statsTab.addEventListener("load", () => { + let report_display = statsTab.document.getElementById("report_display"); + report_display.innerHTML = ""; + + stats.forEach((challenge) => { + let challenge_box = document.createElement("section"); + challenge_box.classList.add("challenge"); + challenge_box.style.width = "100%"; + + let img_url = challenge["image_url"]; + if (img_url !== "") { + let fig = document.createElement("img"); + fig.src = img_url; + fig.classList.add("question-image"); + challenge_box.append(fig); + } + + let question = document.createElement("span"); + question.classList.add("question"); + question.innerHTML = preprocess_inserts(challenge["question"]); + let answer_container = document.createElement("section"); + answer_container.classList.add("answer-container"); + challenge_box.append(question, answer_container); + + let n = challenge["answer_count"]; + for (let i = 0; i < n; i++) { + let answer = challenge["answers"][i]; + let correct_answer = answer === challenge["correct_answer"]; + + let answer_section = document.createElement("section"); + answer_section.classList.add("answer"); + + let progress_bar_container = document.createElement("section"); + progress_bar_container.classList.add("pb-container") + let progress_bar_indicator = document.createElement("section"); + progress_bar_indicator.classList.add("pb-indicator") + + let percentage = challenge["answer_ratio"][i] * 100; + progress_bar_indicator.style.width = `${percentage}%`; + progress_bar_indicator.innerText = Math.round(percentage * 100.0) / 100.0 + "%"; + progress_bar_indicator.setAttribute("correct", correct_answer ? "true" : "false"); + + progress_bar_container.append(progress_bar_indicator); + + let answer_text = document.createElement("span"); + answer_text.classList.add("answer"); + answer_text.innerHTML = preprocess_inserts(answer); + answer_text.setAttribute("correct", correct_answer ? "true" : "false"); + + answer_section.append(progress_bar_container, answer_text); + + answer_container.append(answer_section); + } + + report_display.append(challenge_box); + }); + + statsTab.MathJax.typeset(); + }); + }); +} + +function toggle_test_selection() { + let game_selectChks = document.getElementsByName("game_select"); + game_selectChks.forEach((chk) => { + chk.checked = !chk.checked; + }); +} \ No newline at end of file diff --git a/js/testground.js b/js/testground.js index be54e84..061e687 100644 --- a/js/testground.js +++ b/js/testground.js @@ -1,7 +1,7 @@ let TEST_DATA = {} let INTERVAL_HANDLE = null; -function populate_infobox(test_data) { +function populate_infobox(test_data, view_only) { if (INTERVAL_HANDLE !== null) { clearInterval(INTERVAL_HANDLE); } @@ -12,6 +12,9 @@ function populate_infobox(test_data) { let durationS = document.getElementById("duration"); let percentageS = document.getElementById("percentage"); + let submitBtn = document.getElementById("submit_btn"); + submitBtn.hidden = view_only; + game_nameS.innerHTML = test_data["gamename"]; if (test_concluded) { @@ -28,19 +31,27 @@ function populate_infobox(test_data) { hide("ongoing-info"); show("concluded-info"); } else { + let timerS = document.getElementById("timer"); + let time_left_s = Number(test_data["end_limit_time"]) - Number(test_data["current_time"]); + + let print_timer = () => { + timerS.innerHTML = seconds_to_time(time_left_s); + }; + if (test_data["time_limited"]) { - let timerS = document.getElementById("timer"); - let time_left_s = Number(test_data["end_limit_time"]) - Number(test_data["current_time"]); - seconds_to_time(time_left_s); - INTERVAL_HANDLE = setInterval(() => { - time_left_s--; - timerS.innerHTML = seconds_to_time(time_left_s); - if (time_left_s <= 0) { - populate_all(test_data["_id"]); - clearInterval(INTERVAL_HANDLE); - INTERVAL_HANDLE = null; - } - }, 1000); + print_timer() + + if (!view_only) { + INTERVAL_HANDLE = setInterval(() => { + time_left_s--; + print_timer(); + if (time_left_s <= 0) { + populate_all(test_data["_id"]); + clearInterval(INTERVAL_HANDLE); + INTERVAL_HANDLE = null; + } + }, 1000); + } show("time-info"); } else { hide("time-info"); @@ -59,7 +70,7 @@ function populate_infobox(test_data) { } } -function populate_challenges(test_data) { +function populate_challenges(test_data, view_only = false) { let test_display = document.getElementById("test_display"); test_display.innerHTML = ""; @@ -99,7 +110,7 @@ function populate_challenges(test_data) { answer_radio.type = "radio"; answer_radio.id = `${challenge_N}_${answer_N}`; answer_radio.name = `challenge_${challenge_N}`; - answer_radio.disabled = test_concluded; + answer_radio.disabled = test_concluded || view_only; let answer_N_snapshot = answer_N; answer_radio.addEventListener("input", () => { save_answer(challenge_N_snapshot, answer_N_snapshot); @@ -132,15 +143,16 @@ function populate_challenges(test_data) { MathJax.typeset(); } -function populate_all(test_id) { +function populate_all(test_id, view_only) { let req = { action: "get_test", + view_only: view_only, testid: test_id } request(req).then(resp => { TEST_DATA = JSON.parse(resp); - populate_challenges(TEST_DATA); - populate_infobox(TEST_DATA); + populate_challenges(TEST_DATA, view_only); + populate_infobox(TEST_DATA, view_only); }); } @@ -154,21 +166,6 @@ function save_answer(chidx, aidx) { request(req); } -function preprocess_inserts(str) { - let code_delim = '`'; - let parts = str.split(code_delim); - let res = ""; - for (let i = 0; i < parts.length; i++) { - res += parts[i]; - if (i % 2 === 0) { - res += ""; - } else { - res += ""; - } - } - return res; -} - function submit_test() { let req = { action: "submit_test", diff --git a/report.html b/report.html new file mode 100644 index 0000000..141615b --- /dev/null +++ b/report.html @@ -0,0 +1,20 @@ + + + + + + SpreadQuiz - Részletes jelentés + + + + + + + + + +
+ +
+ + \ No newline at end of file diff --git a/result_analyzer.php b/result_analyzer.php index dd36ddd..bc7d779 100644 --- a/result_analyzer.php +++ b/result_analyzer.php @@ -41,13 +41,23 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat
+ +
+ + - + + @@ -74,6 +90,7 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat diff --git a/style/quizmaster_area.css b/style/quizmaster_area.css index d8b4ac5..38935e7 100644 --- a/style/quizmaster_area.css +++ b/style/quizmaster_area.css @@ -93,4 +93,39 @@ section.window-inner tr td:first-of-type { right: 0; padding: 0.2em; color: #671b17; +} + +a { + text-decoration: none; +} + +section.pb-container { + display: inline-block; + width: 8em; + height: 1em; + margin-right: 1em; +} + +section.pb-indicator { + height: 100%; + background-color: #a1d7d7; + border-radius: 0.3em; + font-size: 0.7em; +} + +section.pb-indicator[correct=true] { + background-color: #176767; + color: whitesmoke; +} + +section#report_display { + width: fit-content; + display: block; + margin: auto; +} + +span.answer[correct=true] { + color: whitesmoke; + background-color: #176767; + border-radius: 0.3em; } \ No newline at end of file diff --git a/testground.php b/testground.php index 631486a..7390347 100644 --- a/testground.php +++ b/testground.php @@ -9,6 +9,7 @@ if (!get_autologin_state() || !isset($_REQUEST["testid"])) { } $testid = trim($_REQUEST["testid"] ?: ""); +$view_only = trim($_REQUEST["view_only"] ?: "false") === "true" ? "true" : "false"; if ($testid === "") { exit(); @@ -39,25 +40,31 @@ if ($testid === "") {
+ +
+ CSAK OLVASHATÓ,
+ NEM FRISSÜL AUTOMATIKUSAN! +
+
Hátralevő idő: -
10:00:00
+
00:00:00
- +
Eredmény: -
95% (19/20)
+
100% (20/20)
\ No newline at end of file diff --git a/testmgr.php b/testmgr.php index 1530de6..488932a 100644 --- a/testmgr.php +++ b/testmgr.php @@ -183,7 +183,23 @@ function conclude_test(string $testid) update_test($test_data); } -function split_criterion(string $crstr): array { +function automatic_typecast(string $rval) +{ + if (is_numeric($rval)) { // is it a numeric value? + if (((int)$rval) == ((double)$rval)) { // is it an integer? + return (int)$rval; + } else { // is it a float? + return (double)$rval; + } + } elseif (str_starts_with($rval, 'T')) { // is it a date/time value? + return strtotime(substr($rval, 1)); // convert to UNIX timestamp + } else { // it's a string + return substr($rval, 1, strlen($rval) - 2); // strip leading and trailing quotes + } +} + +function split_criterion(string $crstr): array +{ preg_match("/([<>=!]+|LIKE|NOT LIKE|IN|NOT IN|CONTAINS|NOT CONTAINS|BETWEEN|NOT BETWEEN|EXISTS)/", $crstr, $matches, PREG_OFFSET_CAPTURE); // extract operator @@ -195,10 +211,18 @@ function split_criterion(string $crstr): array { $right = trim(substr($crstr, $op_pos + strlen($op), strlen($crstr))); // automatic type conversion - if (($right[0] !== "\"") && ($right[0] !== "\'")) { // numeric value - $right = (int) $right; - } else { // string value - $right = substr($right, 1, strlen($right) - 2); // strip leading and trailing quotes + if (str_starts_with($right, "[") && str_ends_with($right, "]")) { // is it an array? + $right = substr($right, 1, -1); // strip leading and trailing brackets + $elements = explode(",", $right); // extract array elements + $right = []; // re-init right value, since it's an array + foreach ($elements as $element) { // insert array elements + $element = trim($element); + if ($element !== "") { + $right[] = automatic_typecast($element); + } + } + } else { // it must be a single value + $right = automatic_typecast($right); } return [$left, $op, $right]; @@ -228,7 +252,10 @@ function build_query(string $filter): array $k++; } elseif ($c === ")") { $k--; - } else { + } + + // only omit parentheses at the top-level expression + if (!((($c === "(") && ($k === 1)) || (($c === ")") && ($k === 0)))) { $buffer .= $c; } @@ -266,21 +293,129 @@ function build_query(string $filter): array } } - return $criteria; } -function get_results_by_gameid(string $gameid, string $filter): array +function build_ordering(string $orderby): array +{ + // don't process empty order instructions + if ($orderby === "") { + return []; + } + + // explode string at tokens delimiting separate order criteria + $ordering = []; + $subcriteria = explode(";", $orderby); + foreach ($subcriteria as $subcriterion) { + $parts = explode(":", $subcriterion); // fetch parts + $field_name = trim($parts[0], "\ \n\r\t\v\0\"'"); // strip leading and trailing quotes if exists + $direction = strtolower(trim($parts[1])); // fetch ordering direction + $ordering[$field_name] = $direction; // build ordering instruction + } + + return $ordering; +} + +function get_results_by_gameid(string $gameid, string $filter, string $orderby, bool $exclude_challenge_data): array { global $testdb; $qb = $testdb->createQueryBuilder(); $qb = $qb->where(["gameid", "=", (int)$gameid]); + // filtering if (trim($filter) !== "") { + + // auto complete starting and ending parenthesis + if (!str_starts_with($filter, "(")) { + $filter = "(" . $filter; + } + if (!str_ends_with($filter, ")")) { + $filter = $filter . ")"; + } + $criteria = build_query($filter); $qb->where($criteria); } + // ordering + if (trim($orderby) !== "") { + $ordering = build_ordering($orderby); + $qb->orderBy($ordering); + } + + // excluding challenge data + if ($exclude_challenge_data) { + $qb->except(["challenges"]); + } + $test_data_array = $qb->getQuery()->fetch(); return $test_data_array; +} + +function generate_detailed_stats(string $gameid, array $testids): array +{ + if ((count($testids) === 0) || ($gameid === "")) { + return []; + } + + global $testdb; + + // fetch relevant entries + $qb = $testdb->createQueryBuilder(); + $criteria = [["gameid", "=", (int)$gameid], "AND", ["state", "=", "concluded"], "AND", ["_id", "IN", $testids]]; + $qb->where($criteria); + $qb->select(["challenges"]); + $entries = $qb->getQuery()->fetch(); + + $challenge_indices = []; + + // count answers + $aggregated = []; + foreach ($entries as $entry) { + foreach ($entry["challenges"] as $challenge) { + $correct_answer = $challenge["answers"][$challenge["correct_answer"]]; + $compound = $challenge["question"] . $correct_answer . count($challenge["answers"]) . $challenge["image_url"]; + $idhash = md5($compound); + + // if this is a new challenge to the list... + if (!isset($challenge_indices[$idhash])) { + $challenge_indices[$idhash] = count($challenge_indices); + $challenge_info = [ // copy challenge info + "hash" => $idhash, + "image_url" => $challenge["image_url"], + "question" => $challenge["question"], + "answers" => $challenge["answers"], + "correct_answer" => $correct_answer, + "player_answers" => array_fill(0, count($challenge["answers"]), 0), + "answer_count" => count($challenge["answers"]), + ]; + $aggregated[$challenge_indices[$idhash]] = $challenge_info; // insert challenge info + } + + // fetch challenge index + $challenge_idx = $challenge_indices[$idhash]; + + // add up player answer + $answer_idx = array_search($challenge["answers"][$challenge["player_answer"]], $aggregated[$challenge_idx]["answers"]); // transform player answer index to report answer index + $aggregated[$challenge_idx]["player_answers"][(int)$answer_idx]++; + } + } + + // produce derived info + foreach ($aggregated as &$entry) { + $entry["answer_ratio"] = $entry["player_answers"]; + $answer_n = count($entry["answer_ratio"]); + $sum = array_sum($entry["player_answers"]); + + if ($sum === 0) { + continue; + } + + for ($i = 0; $i < $answer_n; $i++) { + $entry["answer_ratio"][$i] = $entry["player_answers"][$i] / $sum; + } + } + + // match challenges + return $aggregated; } \ No newline at end of file
+ + #
+ + [_id] + +
Felhasználónév
@@ -60,11 +70,17 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat [result]
Időbélyeg
+
Kezdés ideje
- [timestamp] + [start_time]
Befejezés ideje
+ + [end_time] + +