- report generation implemented

- recursive filtering fixed
- grouping added
This commit is contained in:
Wiesner András 2024-09-09 18:36:03 +02:00
parent 55a68138b5
commit 89ac41a6e6
10 changed files with 410 additions and 58 deletions

View File

@ -112,9 +112,9 @@ switch ($action) {
} }
// test-related queries // test-related queries
if ((($testid = trim($_REQUEST["testid"] ?: "")) !== "") && if (isset($_REQUEST["testid"]) && (($testid = trim($_REQUEST["testid"])) !== "") &&
((count($test_data = get_test($testid))) > 0) && ((count($test_data = get_test($testid))) > 0) &&
($test_data["nickname"] === $nickname)) { (($test_data["nickname"] === $nickname) || $is_quizmaster)) {
// update the test if timed // update the test if timed
update_timed_tests([$test_data]); update_timed_tests([$test_data]);
@ -141,6 +141,9 @@ if ((($testid = trim($_REQUEST["testid"] ?: "")) !== "") &&
} }
} }
break; break;
}
switch ($action) {
case "save_answer": case "save_answer":
{ {
$chidx = $_REQUEST["challenge_index"]; $chidx = $_REQUEST["challenge_index"];
@ -313,12 +316,21 @@ switch ($action) {
{ {
$gameid = trim($_REQUEST["gameid"] ?: ""); $gameid = trim($_REQUEST["gameid"] ?: "");
$filter = trim($_REQUEST["filter"] ?: ""); $filter = trim($_REQUEST["filter"] ?: "");
$ordering = trim($_REQUEST["orderby"] ?: "");
if (($gameid !== "") && (is_user_contributor_to_game($gameid, $nickname) || $is_quizmaster)) { 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); $result = json_encode($game_results);
} }
} }
break; 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 // quizmaster actions

View File

@ -30,4 +30,19 @@ function time_to_seconds(t) {
} }
} }
return s; 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 += "<code>";
} else {
res += "</code>";
}
}
return res;
} }

View File

@ -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); test_group_box.appendChild(test_summary_record);
n--; n--;

View File

@ -1,4 +1,4 @@
function create_cell(content) { function create_cell(content = "") {
let cell = document.createElement("td"); let cell = document.createElement("td");
cell.innerHTML = content; cell.innerHTML = content;
return cell; return cell;
@ -7,8 +7,14 @@ function create_cell(content) {
function fetch_results() { function fetch_results() {
let filterF = document.getElementById("filter"); 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 => { request(req).then(resp => {
let rd = document.getElementById("results_display"); let rd = document.getElementById("results_display");
@ -28,8 +34,9 @@ function fetch_results() {
// is the game concluded // is the game concluded
let concluded = record["state"] === "concluded"; let concluded = record["state"] === "concluded";
let percentage = "-"; let percentage = "";
let timestamp = "-"; let start_timestamp = unix_time_to_human_readable(record["start_time"]);
let end_timestamp = "";
// replace some fields if game was concluded // replace some fields if game was concluded
if (concluded) { if (concluded) {
@ -39,16 +46,31 @@ function fetch_results() {
percentage = `${r}%`; percentage = `${r}%`;
// finish timestamp // finish timestamp
timestamp = unix_time_to_human_readable(record["end_time"]); end_timestamp = unix_time_to_human_readable(record["end_time"]);
} }
// create cells // 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 = `<a href="testground.php?testid=${record["_id"]}&view_only=true" target="_blank">&#x1F50E;</a>`
let inspect_cell = create_cell(inspect_link);
let name_cell = create_cell(record.nickname) let name_cell = create_cell(record.nickname)
let percentage_cell = create_cell(percentage) 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 // append row
rd.appendChild(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;
});
}

View File

@ -1,7 +1,7 @@
let TEST_DATA = {} let TEST_DATA = {}
let INTERVAL_HANDLE = null; let INTERVAL_HANDLE = null;
function populate_infobox(test_data) { function populate_infobox(test_data, view_only) {
if (INTERVAL_HANDLE !== null) { if (INTERVAL_HANDLE !== null) {
clearInterval(INTERVAL_HANDLE); clearInterval(INTERVAL_HANDLE);
} }
@ -12,6 +12,9 @@ function populate_infobox(test_data) {
let durationS = document.getElementById("duration"); let durationS = document.getElementById("duration");
let percentageS = document.getElementById("percentage"); let percentageS = document.getElementById("percentage");
let submitBtn = document.getElementById("submit_btn");
submitBtn.hidden = view_only;
game_nameS.innerHTML = test_data["gamename"]; game_nameS.innerHTML = test_data["gamename"];
if (test_concluded) { if (test_concluded) {
@ -28,19 +31,27 @@ function populate_infobox(test_data) {
hide("ongoing-info"); hide("ongoing-info");
show("concluded-info"); show("concluded-info");
} else { } 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"]) { if (test_data["time_limited"]) {
let timerS = document.getElementById("timer"); print_timer()
let time_left_s = Number(test_data["end_limit_time"]) - Number(test_data["current_time"]);
seconds_to_time(time_left_s); if (!view_only) {
INTERVAL_HANDLE = setInterval(() => { INTERVAL_HANDLE = setInterval(() => {
time_left_s--; time_left_s--;
timerS.innerHTML = seconds_to_time(time_left_s); print_timer();
if (time_left_s <= 0) { if (time_left_s <= 0) {
populate_all(test_data["_id"]); populate_all(test_data["_id"]);
clearInterval(INTERVAL_HANDLE); clearInterval(INTERVAL_HANDLE);
INTERVAL_HANDLE = null; INTERVAL_HANDLE = null;
} }
}, 1000); }, 1000);
}
show("time-info"); show("time-info");
} else { } else {
hide("time-info"); 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"); let test_display = document.getElementById("test_display");
test_display.innerHTML = ""; test_display.innerHTML = "";
@ -99,7 +110,7 @@ function populate_challenges(test_data) {
answer_radio.type = "radio"; answer_radio.type = "radio";
answer_radio.id = `${challenge_N}_${answer_N}`; answer_radio.id = `${challenge_N}_${answer_N}`;
answer_radio.name = `challenge_${challenge_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; let answer_N_snapshot = answer_N;
answer_radio.addEventListener("input", () => { answer_radio.addEventListener("input", () => {
save_answer(challenge_N_snapshot, answer_N_snapshot); save_answer(challenge_N_snapshot, answer_N_snapshot);
@ -132,15 +143,16 @@ function populate_challenges(test_data) {
MathJax.typeset(); MathJax.typeset();
} }
function populate_all(test_id) { function populate_all(test_id, view_only) {
let req = { let req = {
action: "get_test", action: "get_test",
view_only: view_only,
testid: test_id testid: test_id
} }
request(req).then(resp => { request(req).then(resp => {
TEST_DATA = JSON.parse(resp); TEST_DATA = JSON.parse(resp);
populate_challenges(TEST_DATA); populate_challenges(TEST_DATA, view_only);
populate_infobox(TEST_DATA); populate_infobox(TEST_DATA, view_only);
}); });
} }
@ -154,21 +166,6 @@ function save_answer(chidx, aidx) {
request(req); 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 += "<code>";
} else {
res += "</code>";
}
}
return res;
}
function submit_test() { function submit_test() {
let req = { let req = {
action: "submit_test", action: "submit_test",

20
report.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0'>
<title>SpreadQuiz - Részletes jelentés</title>
<script src="js/req.js"></script>
<script src="js/spreadquiz.js"></script>
<script src="js/o.js"></script>
<script src="js/common.js"></script>
<link rel="stylesheet" href="style/spreadquiz.css">
<link rel="stylesheet" href="style/quizmaster_area.css"/>
<script id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
</head>
<body>
<section id="report_display">
</section>
</body>
</html>

View File

@ -41,13 +41,23 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat
<section style="margin-bottom: 0.3em"> <section style="margin-bottom: 0.3em">
<input type="text" placeholder="Szűrőfeltétel" id="filter" style="font-family: 'Monaco', monospace; width: 50em;"> <input type="text" placeholder="Szűrőfeltétel" id="filter" style="font-family: 'Monaco', monospace; width: 50em;">
<input type="text" placeholder="Rendezés" id="orderby" style="font-family: 'Monaco', monospace; width: 50em;">
<input type="button" value="Szűrés" onclick="fetch_results()"> <input type="button" value="Szűrés" onclick="fetch_results()">
<input type="button" value="Jelentés előállítása" onclick="generate_report()">
</section> </section>
<section> <section>
<section id="table_section"> <section id="table_section">
<table class="management"> <table class="management">
<thead> <thead>
<tr> <tr>
<th>
<input type="checkbox" onclick="toggle_test_selection()">
</th>
<th>#<br>
<code>
[_id]
</code>
</th>
<th></th> <th></th>
<th> <th>
Felhasználónév<br> Felhasználónév<br>
@ -60,11 +70,17 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat
[result] [result]
</code> </code>
</th> </th>
<th>Időbélyeg<br> <th>Kezdés ideje<br>
<code> <code>
[timestamp] [start_time]
</code> </code>
</th> </th>
<th>Befejezés ideje<br>
<code>
[end_time]
</code>
</th>
</tr> </tr>
</thead> </thead>
<tbody id="results_display"> <tbody id="results_display">
@ -74,6 +90,7 @@ if (!is_user_contributor_to_game($game_id, $user_data["nickname"]) && ($user_dat
</section> </section>
<script> <script>
let GAMEID = <?="$game_id"?>; let GAMEID = <?="$game_id"?>;
fetch_results();
</script> </script>
</body> </body>
</html> </html>

View File

@ -93,4 +93,39 @@ section.window-inner tr td:first-of-type {
right: 0; right: 0;
padding: 0.2em; padding: 0.2em;
color: #671b17; 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;
} }

View File

@ -9,6 +9,7 @@ if (!get_autologin_state() || !isset($_REQUEST["testid"])) {
} }
$testid = trim($_REQUEST["testid"] ?: ""); $testid = trim($_REQUEST["testid"] ?: "");
$view_only = trim($_REQUEST["view_only"] ?: "false") === "true" ? "true" : "false";
if ($testid === "") { if ($testid === "") {
exit(); exit();
@ -39,25 +40,31 @@ if ($testid === "") {
<section id="infobox"> <section id="infobox">
<span id="game_name"></span> <span id="game_name"></span>
<section> <section>
<?php if ($view_only) { ?>
<section style="margin-bottom: 1em">
CSAK OLVASHATÓ,<br>
NEM FRISSÜL AUTOMATIKUSAN!
</section>
<?php } ?>
<section id="ongoing-info"> <section id="ongoing-info">
<section id="time-info" shown="false"> <section id="time-info" shown="false">
<span class="infobox-description">Hátralevő idő:</span> <span class="infobox-description">Hátralevő idő:</span>
<section id="timer">10:00:00</section> <section id="timer">00:00:00</section>
</section> </section>
<section id="further-info"> <section id="further-info">
</section> </section>
<input type="button" value="Beküld" onclick="submit_test()"> <input type="button" value="Beküld" onclick="submit_test()" id="submit_btn">
</section> </section>
<section id="concluded-info"> <section id="concluded-info">
<section id="duration"></section> <section id="duration"></section>
<span class="infobox-description">Eredmény:</span> <span class="infobox-description">Eredmény:</span>
<section id="percentage">95% (19/20)</section> <section id="percentage">100% (20/20)</section>
</section> </section>
</section> </section>
</section> </section>
<script> <script>
populate_all("<?=$testid ?>"); populate_all("<?=$testid ?>", <?=$view_only ?>);
</script> </script>
</body> </body>
</html> </html>

View File

@ -183,7 +183,23 @@ function conclude_test(string $testid)
update_test($test_data); 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); preg_match("/([<>=!]+|LIKE|NOT LIKE|IN|NOT IN|CONTAINS|NOT CONTAINS|BETWEEN|NOT BETWEEN|EXISTS)/", $crstr, $matches, PREG_OFFSET_CAPTURE);
// extract operator // extract operator
@ -195,10 +211,18 @@ function split_criterion(string $crstr): array {
$right = trim(substr($crstr, $op_pos + strlen($op), strlen($crstr))); $right = trim(substr($crstr, $op_pos + strlen($op), strlen($crstr)));
// automatic type conversion // automatic type conversion
if (($right[0] !== "\"") && ($right[0] !== "\'")) { // numeric value if (str_starts_with($right, "[") && str_ends_with($right, "]")) { // is it an array?
$right = (int) $right; $right = substr($right, 1, -1); // strip leading and trailing brackets
} else { // string value $elements = explode(",", $right); // extract array elements
$right = substr($right, 1, strlen($right) - 2); // strip leading and trailing quotes $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]; return [$left, $op, $right];
@ -228,7 +252,10 @@ function build_query(string $filter): array
$k++; $k++;
} elseif ($c === ")") { } elseif ($c === ")") {
$k--; $k--;
} else { }
// only omit parentheses at the top-level expression
if (!((($c === "(") && ($k === 1)) || (($c === ")") && ($k === 0)))) {
$buffer .= $c; $buffer .= $c;
} }
@ -266,21 +293,129 @@ function build_query(string $filter): array
} }
} }
return $criteria; 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; global $testdb;
$qb = $testdb->createQueryBuilder(); $qb = $testdb->createQueryBuilder();
$qb = $qb->where(["gameid", "=", (int)$gameid]); $qb = $qb->where(["gameid", "=", (int)$gameid]);
// filtering
if (trim($filter) !== "") { 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); $criteria = build_query($filter);
$qb->where($criteria); $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(); $test_data_array = $qb->getQuery()->fetch();
return $test_data_array; 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;
} }