diff --git a/R/admin_utils.R b/R/admin_utils.R index e211098..170d97c 100644 --- a/R/admin_utils.R +++ b/R/admin_utils.R @@ -53,8 +53,9 @@ build_base_url <- function(session) { #' Resets a user's annotation file by keeping the header row and the first #' three columns (coordinates, REF, ALT) but clearing all other data columns. #' Also updates the database by decrementing vote counts for all votes that -#' the user had cast. This allows a user to start voting from scratch while -#' preserving the randomized order of variants. +#' the user had cast. Additionally, resets the vote_input_methods counts in the +#' user's info.json file to 0. This allows a user to start voting from scratch +#' while preserving the randomized order of variants. #' #' @param annotation_file_path Character. Full path to the user's annotation TSV file #' @param user_annotations_colnames Character vector. Column names for the annotation file @@ -182,6 +183,48 @@ reset_user_annotations <- function(annotation_file_path, user_annotations_colnam ) message("Successfully reset annotations for file: ", annotation_file_path) + + # Reset vote_input_methods counts in the user info JSON file + # The info.json file is in the same directory as the annotations file, + # with a similar naming pattern (replacing _annotations.tsv with _info.json) + user_dir <- dirname(annotation_file_path) + base_name <- sub("_annotations\\.tsv$", "", basename(annotation_file_path)) + user_info_file <- file.path(user_dir, paste0(base_name, "_info.json")) + + message("Resetting vote_input_methods in user info file: ", user_info_file) + + if (file.exists(user_info_file)) { + tryCatch({ + # Read the existing user info JSON file + user_info <- jsonlite::read_json(user_info_file) + + # Reset vote_input_methods counts to 0 + if (!is.null(user_info$vote_input_methods)) { + user_info$vote_input_methods <- list( + hotkey_count = 0, + mouse_count = 0, + unknown_count = 0 + ) + + # Write the updated user info back to the file + jsonlite::write_json( + user_info, + user_info_file, + auto_unbox = TRUE, + pretty = TRUE + ) + + message("Successfully reset vote_input_methods for file: ", user_info_file) + } else { + message("No vote_input_methods found in user info file: ", user_info_file) + } + }, error = function(e) { + warning("Failed to reset vote_input_methods in user info file: ", e$message) + }) + } else { + message("User info file does not exist: ", user_info_file) + } + return(TRUE) }, error = function(e) { warning("Failed to reset annotations: ", e$message) diff --git a/R/main_server.R b/R/main_server.R index 1c41b3b..2b2f971 100644 --- a/R/main_server.R +++ b/R/main_server.R @@ -179,7 +179,12 @@ makeVotingAppServer <- function(db_pool, cfg) { user_info <- list( user_id = user_id, voting_institute = voting_institute, - images_randomisation_seed = seed + images_randomisation_seed = seed, + vote_input_methods = list( + hotkey_count = 0, + mouse_count = 0, + unknown_count = 0 + ) ) session$userData$sessionInfo <- list( diff --git a/R/mod_voting.R b/R/mod_voting.R index d0c1261..ed8b72c 100644 --- a/R/mod_voting.R +++ b/R/mod_voting.R @@ -439,6 +439,54 @@ votingServer <- function( quote = FALSE ) + # Update user_info.json with input method tracking + if (!already_voted) { + user_info_file <- session$userData$userInfoFile + if (file.exists(user_info_file)) { + user_info <- jsonlite::read_json(user_info_file) + + # Get the input method (default to "unknown" if not set to avoid misattribution) + input_method <- input$last_input_method + if (is.null(input_method) || input_method == "") { + input_method <- "unknown" + warning("Input method not captured for vote, using 'unknown'") + } + + # Initialize vote_input_methods if it doesn't exist + if (is.null(user_info$vote_input_methods)) { + user_info$vote_input_methods <- list( + hotkey_count = 0, + mouse_count = 0, + unknown_count = 0 + ) + } + + # Update the count based on input method + if (input_method == "hotkey") { + user_info$vote_input_methods$hotkey_count <- + user_info$vote_input_methods$hotkey_count + 1 + } else if (input_method == "mouse") { + user_info$vote_input_methods$mouse_count <- + user_info$vote_input_methods$mouse_count + 1 + } else { + # Track unknown cases for debugging + if (is.null(user_info$vote_input_methods$unknown_count)) { + user_info$vote_input_methods$unknown_count <- 0 + } + user_info$vote_input_methods$unknown_count <- + user_info$vote_input_methods$unknown_count + 1 + } + + # Write updated user_info back to file + jsonlite::write_json( + user_info, + user_info_file, + auto_unbox = TRUE, + pretty = TRUE + ) + } + } + if ( already_voted && previous_agreement != input$agreement diff --git a/VOTE_INPUT_TRACKING.md b/VOTE_INPUT_TRACKING.md new file mode 100644 index 0000000..251936f --- /dev/null +++ b/VOTE_INPUT_TRACKING.md @@ -0,0 +1,111 @@ +# Vote Input Method Tracking + +## Overview +This document describes the implementation of vote input method tracking in ShinyImageVoteR. This feature tracks whether users cast votes using keyboard hotkeys or mouse clicks to help analyze the relationship between input method and voting speed. + +## Motivation +Analysis revealed that median vote times range from 2-15 seconds across users. We want to understand how much of this difference is explained by hotkey usage versus mouse clicking. + +## Implementation + +### 1. JavaScript Changes (hotkeys.js) + +#### Hotkey Detection +When a user presses a hotkey (1, 2, 3, 4 for radio buttons or a, s, d, f for checkboxes): +- The input element is marked with `dataset.inputMethod = "hotkey"` +- `Shiny.setInputValue("voting-last_input_method", "hotkey")` sends this information to R + +#### Mouse Click Detection +When a user clicks on a radio button or checkbox: +- The click is verified to be within the voting questions div (same validation as hotkeys) +- The input element is marked with `dataset.inputMethod = "mouse"` +- `Shiny.setInputValue("voting-last_input_method", "mouse")` sends this information to R + +**Note**: This validation prevents accidental tracking of clicks on other inputs with the same names elsewhere in the application. + +### 2. R Server Changes + +#### User Info Structure (main_server.R) +When a user logs in, their `user_info.json` file is initialized with: +```json +{ + "user_id": "User1", + "voting_institute": "InstituteA", + "images_randomisation_seed": 12345, + "vote_input_methods": { + "hotkey_count": 0, + "mouse_count": 0, + "unknown_count": 0 + } +} +``` + +**Note**: The `unknown_count` field tracks cases where the input method couldn't be determined, which helps identify potential issues with the tracking mechanism. + +#### Vote Tracking (mod_voting.R) +When a user casts a **new vote** (not a vote change): +1. The input method is read from `input$last_input_method` +2. If not set, it's marked as "unknown" (with a warning logged) +3. The `user_info.json` file is read +4. The appropriate counter (`hotkey_count`, `mouse_count`, or `unknown_count`) is incremented +5. The updated info is written back to `user_info.json` + +**Note**: +- Vote changes are not tracked to avoid skewing the data. We only track initial votes. +- Using "unknown" instead of defaulting to "mouse" ensures accurate data and helps identify tracking issues. + +## Data Analysis + +After users complete their voting sessions, the `user_info.json` files can be analyzed to: +1. Calculate the percentage of hotkey vs mouse usage per user +2. Correlate this with average voting times (from `time_till_vote_casted_in_seconds` in annotations) +3. Determine if hotkey users are significantly faster + +### Example Analysis Query +For each user: +- Read `user_info.json` to get hotkey/mouse counts +- Read `user_annotations.tsv` to get vote times +- Calculate: + - Hotkey usage percentage: `hotkey_count / (hotkey_count + mouse_count) * 100` + - Median vote time: median of `time_till_vote_casted_in_seconds` +- Plot correlation between hotkey usage and vote speed + +## Files Modified + +1. **inst/shiny-app/www/js/hotkeys.js**: Added input method detection and Shiny communication +2. **vignettes/www/hotkeys.js**: Updated to match inst version +3. **R/main_server.R**: Added `vote_input_methods` initialization to user_info +4. **R/mod_voting.R**: Added tracking logic to update user_info.json after each vote + +## Testing + +To test this implementation: +1. Start the Shiny app +2. Log in as a user +3. Vote using hotkeys (press 1, 2, 3, or 4) +4. Vote using mouse clicks +5. Check the `user_info.json` file for the user - it should show updated counts + +Example: +```json +{ + "user_id": "TestUser", + "voting_institute": "TestInstitute", + "images_randomisation_seed": 67890, + "vote_input_methods": { + "hotkey_count": 12, + "mouse_count": 5, + "unknown_count": 0 + } +} +``` + +**Note**: If `unknown_count` is greater than 0, it indicates potential issues with the tracking mechanism that should be investigated. + +## Future Enhancements + +Potential improvements: +1. Track vote changes separately to understand behavior patterns +2. Add per-vote logs instead of just counts for more detailed analysis +3. Add real-time dashboard showing hotkey vs mouse usage statistics +4. Export aggregated statistics across all users diff --git a/inst/shiny-app/www/js/hotkeys.js b/inst/shiny-app/www/js/hotkeys.js index 470fd57..31362d9 100644 --- a/inst/shiny-app/www/js/hotkeys.js +++ b/inst/shiny-app/www/js/hotkeys.js @@ -92,7 +92,41 @@ document.addEventListener("keydown", (e) => { if (!input) return; input.checked = toggle ? !input.checked : true; + // Mark this input as triggered via hotkey + input.dataset.inputMethod = "hotkey"; + // Send to Shiny for tracking + if (window.Shiny) { + Shiny.setInputValue("voting-last_input_method", "hotkey", { priority: "event" }); + } input.dispatchEvent(new Event("change", { bubbles: true })); return; } }); + +// Mark mouse clicks on voting inputs +document.addEventListener("click", (e) => { + const target = e.target; + + // Check if click is on a radio button or checkbox in voting groups + if (target.type === "radio" || target.type === "checkbox") { + if (target.name === "voting-agreement" || target.name === "voting-observation") { + // Verify the target is within the voting questions div (same as hotkey handler) + const questionsDiv = document.getElementById("voting-voting_questions_div"); + if (!questionsDiv || questionsDiv.offsetParent === null) { + return; + } + + // Check if target is within the questions div + if (!questionsDiv.contains(target)) { + return; + } + + // Mark this input as triggered via mouse + target.dataset.inputMethod = "mouse"; + // Send to Shiny for tracking + if (window.Shiny) { + Shiny.setInputValue("voting-last_input_method", "mouse", { priority: "event" }); + } + } + } +}); diff --git a/tests/testthat/test-admin-reset-annotations.R b/tests/testthat/test-admin-reset-annotations.R index ccac793..0673a29 100644 --- a/tests/testthat/test-admin-reset-annotations.R +++ b/tests/testthat/test-admin-reset-annotations.R @@ -312,3 +312,142 @@ testthat::test_that("reset_user_annotations correctly decrements vote counts", { unlink(mock_db$file) }) +testthat::test_that("reset_user_annotations resets vote_input_methods in user info JSON", { + # Create temporary annotation file and user info JSON file + temp_dir <- tempdir() + + # Create annotation file + temp_file <- file.path(temp_dir, "testuser_annotations.tsv") + user_annotations_colnames <- c( + "coordinates", "REF", "ALT", "agreement", + "observation", "comment", "shinyauthr_session_id", + "time_till_vote_casted_in_seconds" + ) + + test_data <- data.frame( + coordinates = c("chr1:100"), + REF = c("A"), + ALT = c("T"), + agreement = c("yes"), + observation = c("coverage"), + comment = c("comment1"), + shinyauthr_session_id = c("sess1"), + time_till_vote_casted_in_seconds = c("10"), + stringsAsFactors = FALSE + ) + + write.table( + test_data, + file = temp_file, + sep = "\t", + row.names = FALSE, + col.names = TRUE, + quote = FALSE + ) + + # Create corresponding user info JSON file + user_info_file <- file.path(temp_dir, "testuser_info.json") + user_info <- list( + user_id = "testuser", + voting_institute = "institute1", + images_randomisation_seed = 12345, + vote_input_methods = list( + hotkey_count = 5, + mouse_count = 3, + unknown_count = 1 + ) + ) + + jsonlite::write_json( + user_info, + user_info_file, + auto_unbox = TRUE, + pretty = TRUE + ) + + # Create mock database + mock_db <- create_mock_db() + db_pool <- mock_db$pool + + # Insert test variant into database + DBI::dbExecute( + db_pool, + "INSERT INTO annotations (coordinates, REF, ALT, path) VALUES (?, ?, ?, ?)", + params = list("chr1:100", "A", "T", "/test/path.png") + ) + + # Reset the annotations (should also reset vote_input_methods) + result <- reset_user_annotations(temp_file, user_annotations_colnames, db_pool, create_mock_config()) + + # Check that reset was successful + testthat::expect_true(result) + + # Read the updated user info JSON file + updated_user_info <- jsonlite::read_json(user_info_file) + + # Verify vote_input_methods were reset to 0 + testthat::expect_equal(updated_user_info$vote_input_methods$hotkey_count, 0) + testthat::expect_equal(updated_user_info$vote_input_methods$mouse_count, 0) + testthat::expect_equal(updated_user_info$vote_input_methods$unknown_count, 0) + + # Verify other fields were preserved + testthat::expect_equal(updated_user_info$user_id, "testuser") + testthat::expect_equal(updated_user_info$voting_institute, "institute1") + testthat::expect_equal(updated_user_info$images_randomisation_seed, 12345) + + # Clean up + pool::poolClose(db_pool) + unlink(temp_file) + unlink(user_info_file) + unlink(mock_db$file) +}) + +testthat::test_that("reset_user_annotations handles missing user info JSON gracefully", { + # Create annotation file without corresponding info JSON + temp_file <- tempfile(fileext = "_annotations.tsv") + + user_annotations_colnames <- c( + "coordinates", "REF", "ALT", "agreement" + ) + + test_data <- data.frame( + coordinates = c("chr1:100"), + REF = c("A"), + ALT = c("T"), + agreement = c("yes"), + stringsAsFactors = FALSE + ) + + write.table( + test_data, + file = temp_file, + sep = "\t", + row.names = FALSE, + col.names = TRUE, + quote = FALSE + ) + + # Create mock database + mock_db <- create_mock_db() + db_pool <- mock_db$pool + + # Insert test variant into database + DBI::dbExecute( + db_pool, + "INSERT INTO annotations (coordinates, REF, ALT, path) VALUES (?, ?, ?, ?)", + params = list("chr1:100", "A", "T", "/test/path.png") + ) + + # Reset the annotations (should succeed even without info.json file) + result <- reset_user_annotations(temp_file, user_annotations_colnames, db_pool, create_mock_config()) + + # Should still succeed - missing info.json is not a fatal error + testthat::expect_true(result) + + # Clean up + pool::poolClose(db_pool) + unlink(temp_file) + unlink(mock_db$file) +}) + + diff --git a/vignettes/www/hotkeys.js b/vignettes/www/hotkeys.js index 3b09407..d1e23e6 100644 --- a/vignettes/www/hotkeys.js +++ b/vignettes/www/hotkeys.js @@ -5,6 +5,15 @@ document.addEventListener("keydown", (e) => { return; } + // Disable hotkeys when fullscreen overlay is shown + const fullscreenOverlay = document.getElementById("fullscreen-overlay"); + if (fullscreenOverlay) { + const overlayDisplay = window.getComputedStyle(fullscreenOverlay).display; + if (overlayDisplay !== "none") { + return; + } + } + console.log("Key pressed:", e.key); // ——— special buttons ——— if (e.key === "Enter") { @@ -83,7 +92,41 @@ document.addEventListener("keydown", (e) => { if (!input) return; input.checked = toggle ? !input.checked : true; + // Mark this input as triggered via hotkey + input.dataset.inputMethod = "hotkey"; + // Send to Shiny for tracking + if (window.Shiny) { + Shiny.setInputValue("voting-last_input_method", "hotkey", { priority: "event" }); + } input.dispatchEvent(new Event("change", { bubbles: true })); return; } }); + +// Mark mouse clicks on voting inputs +document.addEventListener("click", (e) => { + const target = e.target; + + // Check if click is on a radio button or checkbox in voting groups + if (target.type === "radio" || target.type === "checkbox") { + if (target.name === "voting-agreement" || target.name === "voting-observation") { + // Verify the target is within the voting questions div (same as hotkey handler) + const questionsDiv = document.getElementById("voting-voting_questions_div"); + if (!questionsDiv || questionsDiv.offsetParent === null) { + return; + } + + // Check if target is within the questions div + if (!questionsDiv.contains(target)) { + return; + } + + // Mark this input as triggered via mouse + target.dataset.inputMethod = "mouse"; + // Send to Shiny for tracking + if (window.Shiny) { + Shiny.setInputValue("voting-last_input_method", "mouse", { priority: "event" }); + } + } + } +});