diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..12b911d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.gs text eol=lf +*.html text eol=lf +*.json text eol=lf +*.bat text eol=crlf \ No newline at end of file diff --git a/Code.gs b/Code.gs index 096de57..ff49d9a 100644 --- a/Code.gs +++ b/Code.gs @@ -1,118 +1,176 @@ function doGet() { - return HtmlService.createTemplateFromFile("Index") - .evaluate() - .setTitle("Robotics Club Ops") - .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) - .addMetaTag("viewport", "width=device-width, initial-scale=1"); + return HtmlService.createTemplateFromFile("Index") + .evaluate() + .setTitle("Robotics Club Ops") + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) + .addMetaTag("viewport", "width=device-width, initial-scale=1"); } -// Standard HTML Service include function -// This allows you to inject content from other files into Index.html +// This allows us to modularize our HTML/CSS/JS function include(filename) { - return HtmlService.createHtmlOutputFromFile(filename).getContent(); + return HtmlService.createHtmlOutputFromFile(filename).getContent(); } -/* 1. GET DATA FOR DROPDOWN */ +// SPREADSHEET COLUMN SETTINGS +// Update these numbers if columns are added or moved in the "Students" sheet (1 = A, 2 = B, ...) +// Note that Java constants are zero-indexed, while Google Sheets columns are 1-indexed. +// These are the 1-indexed column numbers. +const COL_NAME = 1; +const COL_GRADE = 2; +const COL_STATUS = 3; +const COL_LAST_IN = 4; +const COL_LAST_OUT = 5; +const COL_GRADE_CHANGED = 6; +const COL_DATE_ADDED = 7; + +/** Get data for dropdown */ function getStudentList() { - const ss = SpreadsheetApp.getActiveSpreadsheet(); - const sheet = ss.getSheetByName("Students"); + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const sheet = ss.getSheetByName("Students"); + + if (!sheet || sheet.getLastRow() <= 1) return {}; + const data = sheet.getDataRange().getValues(); + + // Remove headers + if (data.length > 0) data.shift(); + let studentMap = {}; + + // Build a map of normalized name -> { displayName, grade } + data.forEach((row) => { + const nameValue = row[COL_NAME - 1]; + const gradeValue = row[COL_GRADE - 1]; + + // Skip empty rows + if (nameValue) { + const displayName = String(nameValue).trim(); + const normName = displayName.toLowerCase(); + + studentMap[normName] = { + displayName: displayName, + grade: gradeValue + }; + } + }); + + return studentMap; +} - // Handle empty sheet case - if (sheet.getLastRow() <= 1) return {}; +/** Handle form submission */ +function processForm(formObject) { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const studentSheet = ss.getSheetByName("Students"); + const logsSheet = ss.getSheetByName("Logs"); - const data = sheet.getDataRange().getValues(); + // Error handling for missing sheets + if (!studentSheet || !logsSheet) { + return { status: "ERROR", message: "Missing required sheet(s)." }; + } - // Remove headers - if (data.length > 0) data.shift(); + // Sanitize and normalize input + let name = String(formObject.studentName || "").trim(); + let normName = name.toLowerCase(); + let grade = String(formObject.grade || "").trim(); + const type = String(formObject.checkType || "").trim(); // "Check In" or "Check Out" + const notes = String(formObject.notes || "").trim(); + const timeString = String(formObject.manualTime || "").trim(); // HH:mm + + // Validate Grade + if (!["9", "10", "11", "12"].includes(grade)) { + return { status: "ERROR", message: "Invalid grade." }; + } - let studentMap = {}; - data.forEach((row) => { - // Name is Col A (index 0), Grade is Col B (index 1) - if (row[0]) { - studentMap[row[0]] = row[1]; + // Validate Name + if (!name) { + return { status: "ERROR", message: "Name required." }; } - }); - return studentMap; -} + // Validate Time format (HH:mm) + if (!/^\d{2}:\d{2}$/.test(timeString)) { + return { status: "ERROR", message: "Invalid time format." }; + } -/* 2. HANDLE FORM SUBMISSION */ -function processForm(formObject) { - const ss = SpreadsheetApp.getActiveSpreadsheet(); - const studentSheet = ss.getSheetByName("Students"); - const logsSheet = ss.getSheetByName("Logs"); - - const name = formObject.studentName; - const grade = formObject.grade; - const type = formObject.checkType; // "Check In" or "Check Out" - const notes = formObject.notes || ""; - const timeString = formObject.manualTime; // HH:mm - - const now = new Date(); - const dateString = Utilities.formatDate( - now, - Session.getScriptTimeZone(), - "yyyy-MM-dd", - ); - const timestamp = Utilities.formatDate( - now, - Session.getScriptTimeZone(), - "yyyy-MM-dd HH:mm:ss", - ); - - // A. UPDATE STUDENT RECORD (Status, Last Seen, Grade, etc.) - updateStudentRecord(studentSheet, name, grade, type, timestamp); - - // B. LOG DATA TO LOGS TAB - // Logs: Name, Grade, Type, Time, Date, Notes - logsSheet.appendRow([name, grade, type, timeString, dateString, notes]); - - return { status: "SUCCESS", type: type }; + const now = new Date(); + + if (timeString) { + const [hours, minutes] = timeString.split(':'); + now.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0); + } + + // If manual time is provided, override the hours and minutes of 'now' + const dateString = Utilities.formatDate( + now, + Session.getScriptTimeZone(), + "yyyy-MM-dd", + ); + + const timestamp = Utilities.formatDate( + now, + Session.getScriptTimeZone(), + "yyyy-MM-dd HH:mm:ss", + ); + + // Update student record (Status, Last Seen, Grade, etc.) + try { + updateStudentRecord(studentSheet, name, grade, type, timestamp); + // Log data to Logs tab + logsSheet.appendRow([name, grade, type, timeString, dateString, notes]); + return { status: "SUCCESS", type: type }; + } catch (e) { + return { status: "ERROR", message: "Sheet operation failed: " + e.message }; + } } -/* 3. CORE LOGIC FOR UPDATING STUDENTS SHEET */ +/** Core logic for updating Students sheet */ function updateStudentRecord(sheet, name, newGrade, type, timestamp) { - const data = sheet.getDataRange().getValues(); - let rowIndex = -1; - - // 1. FIND ROW - for (let i = 1; i < data.length; i++) { - if (data[i][0] === name) { - rowIndex = i + 1; // Convert 0-index to Sheet Row Number - break; + const data = sheet.getDataRange().getValues(); + let rowIndex = -1; + let normName = String(name).trim().toLowerCase(); + + // Find row (case-insensitive, trim) + for (let i = 1; i < data.length; i++) { + if (String(data[i][COL_NAME - 1]).trim().toLowerCase() === normName) { + rowIndex = i + 1; + break; + } + } + + // If new student, add them + if (rowIndex === -1) { + // Defend against missing columns by ensuring newRow has enough elements + let maxCol = Math.max(COL_DATE_ADDED, sheet.getLastColumn()); + let newRow = new Array(maxCol).fill(""); + + newRow[COL_NAME - 1] = name; + newRow[COL_GRADE - 1] = newGrade; + newRow[COL_DATE_ADDED - 1] = timestamp; + + sheet.appendRow(newRow); + rowIndex = sheet.getLastRow(); // Get the new row number + } + + // Check for grade change + const gradeCell = sheet.getRange(rowIndex, COL_GRADE); + const currentGrade = gradeCell.getValue(); + + // Only update if Grade is different + if (String(currentGrade) !== String(newGrade)) { + gradeCell.setValue(newGrade); + // Update "Grade Last Changed" + sheet.getRange(rowIndex, COL_GRADE_CHANGED).setValue(timestamp); + } + + // Update Status and Last Seen + const statusCell = sheet.getRange(rowIndex, COL_STATUS); + + if (type === "Check In") { + // Set Status text and color + statusCell.setValue("Checked In").setBackground("#d9ead3"); // Light green + // Update Last Check In + sheet.getRange(rowIndex, COL_LAST_IN).setValue(timestamp); + } else { + // Set Status text and color + statusCell.setValue("Checked Out").setBackground("#f4cccc"); // Light red + // Update Last Check Out + sheet.getRange(rowIndex, COL_LAST_OUT).setValue(timestamp); } - } - - // 2. IF NEW STUDENT (Add them) - if (rowIndex === -1) { - // Append: Name, Grade, Status(empty), LastIn(empty), LastOut(empty), GradeChanged(empty), DateAdded - sheet.appendRow([name, newGrade, "", "", "", "", timestamp]); - rowIndex = sheet.getLastRow(); // Get the new row number - } - - // 3. CHECK FOR GRADE CHANGE (Col B -> Column 2) - const gradeCell = sheet.getRange(rowIndex, 2); - const currentGrade = gradeCell.getValue(); - - // Only update if grade is different - if (String(currentGrade) !== String(newGrade)) { - gradeCell.setValue(newGrade); - // Update "Grade Last Changed" (Col F -> Column 6) - sheet.getRange(rowIndex, 6).setValue(timestamp); - } - - // 4. UPDATE STATUS & LAST SEEN - const statusCell = sheet.getRange(rowIndex, 3); // Col C - - if (type === "Check In") { - // Set Status Text & Color - statusCell.setValue("Checked In").setBackground("#d9ead3"); // Light Green - // Update Last Check In (Col D -> Column 4) - sheet.getRange(rowIndex, 4).setValue(timestamp); - } else { - // Set Status Text & Color - statusCell.setValue("Checked Out").setBackground("#f4cccc"); // Light Red - // Update Last Check Out (Col E -> Column 5) - sheet.getRange(rowIndex, 5).setValue(timestamp); - } } diff --git a/Index.html b/Index.html index 75cb6fc..64126e1 100644 --- a/Index.html +++ b/Index.html @@ -1,121 +1,147 @@ - + + + href="https://fonts.googleapis.com/css2?family=Exo+2:wght@500;700&family=Orbitron:wght@400;700&family=Roboto+Mono:wght@300;400&display=swap" + rel="stylesheet" /> - + - - + + +
-

FRC Check In/Out

- -
- - - - -
- -
    -
    +

    FRC Check In/Out

    - -
    - - - - -
    + + + - -
    - - - -
    - - - -
    - - +
    + STUDENT NAME +
    + + + ? + +
      +
      + + + + +
      + +
      + GRADE +
      + + + + +
      + + + +
      + + +
      + TIME +
      + + + +
      +
      + + +
      + NOTES + +
      + +
      + + +
      + + +
      +
      +

      + PROCESSING... +

      - - -
      -
      -

      - PROCESSING... -

      -
      - + - - + + + + + + \ No newline at end of file diff --git a/JavaScript.html b/JavaScript.html index 88261f8..b825e57 100644 --- a/JavaScript.html +++ b/JavaScript.html @@ -1,243 +1,514 @@ + document.addEventListener("click", function (e) { + const wrapper = document.querySelector(".input-wrapper"); + if (!wrapper.contains(e.target)) { + document.getElementById("suggestions").style.display = "none"; + } + }); + + // Keyboard navigation for the suggestions dropdown + document.getElementById("studentNameInput").addEventListener("keydown", function (e) { + const list = document.getElementById("suggestions"); + const items = list.getElementsByTagName("li"); + + // Only process navigation if the dropdown is visible and has items + if (list.style.display === "block" && items.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); // Prevent cursor from jumping to the end of the text box + currentSuggestionIndex++; + if (currentSuggestionIndex >= items.length) currentSuggestionIndex = 0; // Wrap to top + setActiveSuggestion(items); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + currentSuggestionIndex--; + if (currentSuggestionIndex < 0) currentSuggestionIndex = items.length - 1; // Wrap to bottom + setActiveSuggestion(items); + } else if (e.key === "Enter") { + e.preventDefault(); // Prevent standard form submission + if (currentSuggestionIndex > -1) { + // Simulate a click on the highlighted item + items[currentSuggestionIndex].click(); + } + } else if (e.key === "Escape") { + list.style.display = "none"; + currentSuggestionIndex = -1; + } + } else if (e.key === "Enter") { + // Prevent accidental form submission if they hit Enter while the list is closed + e.preventDefault(); + } + }); + + // Helper function to update the visual state of the list + function setActiveSuggestion(items) { + // Clear the active class from all items + for (let i = 0; i < items.length; i++) { + items[i].classList.remove("active"); + } + + // Apply the active class to the selected item + if (currentSuggestionIndex > -1 && currentSuggestionIndex < items.length) { + items[currentSuggestionIndex].classList.add("active"); + + // Automatically scroll the list if the highlighted item goes out of view + items[currentSuggestionIndex].scrollIntoView({ block: "nearest" }); + } + } + \ No newline at end of file diff --git a/Stylesheet.html b/Stylesheet.html index 99b1e7d..3f7c2b5 100644 --- a/Stylesheet.html +++ b/Stylesheet.html @@ -1,283 +1,514 @@ + + + /* ========================================= + 1. CSS Variables + ========================================= */ + :root { + --neon-blue: #00f3ff; + --neon-green: #0aff60; + --neon-red: #ff0055; + --dark-bg: #0b0c10; + --panel-bg: #1f2833; + --text-color: #c5c6c7; + } + + /* ========================================= + 2. Base & Resets + ========================================= */ + body { + background-color: var(--dark-bg); + color: var(--text-color); + font-family: "Roboto Mono", monospace; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + margin: 0; + background-image: + linear-gradient(rgba(0, 243, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 243, 255, 0.03) 1px, transparent 1px); + background-size: 20px 20px; + } + + h1 { + font-family: "Orbitron", sans-serif; + color: var(--neon-blue); + text-align: center; + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 2.5rem; + text-shadow: 0 0 10px var(--neon-blue); + } + + label { + display: block; + margin-top: 20px; + margin-bottom: 6px; + font-size: 0.9rem; + color: var(--neon-blue); + } + + form>label:first-of-type { + margin-top: 0; + } + + /* ========================================= + 3. Layout & Structural Containers + ========================================= */ + .container { + background-color: var(--panel-bg); + padding: 2rem; + border-radius: 10px; + box-shadow: 0 0 20px rgba(0, 243, 255, 0.2); + border: 1px solid var(--neon-blue); + width: 100%; + max-width: 400px; + position: relative; + } + + .styled-section { + margin-bottom: 1.5rem; + border: 1px solid var(--neon-blue); + border-radius: 8px; + padding: 1rem; + background: rgba(0, 243, 255, 0.05); + } + + /* ========================================= + 4. Global Inputs + ========================================= */ + input[type="text"] { + width: 100%; + padding: 10px; + margin-top: 0; + margin-bottom: 5px; + background: #0b0c10; + border: 1px solid #45a29e; + color: white; + font-family: "Roboto Mono", monospace; + box-sizing: border-box; + } + + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* ========================================= + 5. Specific Components + ========================================= */ + + /* --- A. Name Input & Suggestions --- */ + .input-wrapper { + position: relative; + width: 100%; + } + + #studentNameInput { + padding-right: 60px; + } + + .name-indicator { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + margin-top: -3px; + font-family: "Orbitron", sans-serif; + font-size: 1.1rem; + font-weight: 700; + pointer-events: none; + transition: color 0.3s, transform 0.3s; + letter-spacing: 2px; + } + + .indicator-unknown { + color: #888; + } + + .indicator-known { + color: var(--neon-green); + text-shadow: 0 0 8px rgba(10, 255, 96, 0.4); + } + + .indicator-new { + color: #ff9d00; + text-shadow: 0 0 8px rgba(255, 157, 0, 0.4); + } + + .suggestions-list { + display: none; + position: absolute; + top: 100%; + left: 0; + width: 100%; + max-height: 200px; + overflow-y: auto; + background-color: var(--panel-bg); + border: 1px solid var(--neon-blue); + border-top: none; + list-style: none; + padding: 0; + margin: 0; + z-index: 1000; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.8); + } + + .suggestions-list::-webkit-scrollbar { + width: 8px; + background: var(--dark-bg); + } + + .suggestions-list::-webkit-scrollbar-thumb { + background: var(--neon-blue); + border-radius: 4px; + } + + .suggestions-list li { + padding: 12px; + border-bottom: 1px solid #333; + color: var(--text-color); + cursor: pointer; + transition: 0.2s; + font-size: 0.9rem; + } + + .suggestions-list li:hover, + .suggestions-list li.active { + background-color: rgba(0, 243, 255, 0.1); + color: white; + padding-left: 15px; + border-left: 3px solid var(--neon-blue); + } + + .suggestions-list li strong { + color: var(--neon-green); + } + + /* --- B. Grade Selection --- */ + .radio-group { + display: flex; + justify-content: space-between; + margin-top: 0; + } + + .radio-option { + position: relative; + cursor: pointer; + } + + .radio-option input { + opacity: 0; + position: absolute; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 2; + } + + .radio-option span { + display: inline-block; + padding: 8px 12px; + border: 1px solid #45a29e; + color: #45a29e; + transition: 0.3s; + font-size: 1rem; + font-weight: 700; + opacity: 0.8; + cursor: pointer; + position: relative; + background: rgba(0, 243, 255, 0.05); + z-index: 1; + letter-spacing: 1px; + text-shadow: 0 0 2px #222, 0 0 1px #00f3ff44; + } + + .radio-option input:disabled { + cursor: not-allowed; + } + + .radio-option input:disabled+span { + background: repeating-linear-gradient(135deg, rgba(136, 136, 136, 0.18) 0 2px, transparent 2px 8px); + cursor: not-allowed; + opacity: 0.5; + } + + .radio-option input:not(:disabled):hover+span { + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); + border-color: var(--neon-blue); + } + + .radio-option input:checked+span { + background: var(--neon-blue); + color: #000; + box-shadow: 0 0 10px var(--neon-blue); + font-weight: bold; + } + + .radio-option input:focus+span { + box-shadow: 0 0 15px white, 0 0 5px var(--neon-blue); + border-color: white; + transform: scale(1.05); + } + + /* --- C. Time Section --- */ + .time-container { + display: flex; + justify-content: space-between; + gap: 10px; + } + + .time-box { + background: #0b0c10; + border: 1px solid #45a29e; + color: white; + font-family: "Exo 2", sans-serif; + font-weight: 700; + font-size: 1.5rem; + padding: 10px; + text-align: center; + width: 100%; + transition: color 0.3s, opacity 0.3s; + } + + .time-box:focus { + outline: none; + border-color: var(--neon-green); + box-shadow: 0 0 8px rgba(10, 255, 96, 0.5); + } + + .time-box.auto-sync { + color: #888 !important; + opacity: 0.7; + } + + /* ========================================= + 6. Buttons + ========================================= */ + .button-group { + display: flex; + gap: 15px; + margin-top: 25px; + } + + .action-btn { + flex: 1; + padding: 15px; + font-family: "Orbitron", sans-serif; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + text-transform: uppercase; + background: transparent; + transition: 0.3s; + } + + .btn-green { + border: 2px solid var(--neon-green); + color: var(--neon-green); + } + + .btn-green:hover, + .btn-green:focus { + background: var(--neon-green); + color: #000; + box-shadow: 0 0 20px var(--neon-green); + } + + .btn-red { + border: 2px solid var(--neon-red); + color: var(--neon-red); + } + + .btn-red:hover, + .btn-red:focus { + background: var(--neon-red); + color: white; + box-shadow: 0 0 20px var(--neon-red); + } + + /* ========================================= + 7. Modals, Popups & Tooltips + ========================================= */ + .popup-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.7); + justify-content: center; + align-items: center; + z-index: 10000; + } + + .popup-content { + background: var(--panel-bg); + border: 2px solid var(--neon-blue); + border-radius: 10px; + padding: 2rem 2.5rem 1.5rem 2.5rem; + color: var(--text-color); + box-shadow: 0 0 30px var(--neon-blue); + text-align: center; + min-width: 260px; + max-width: 90vw; + } + + #customPopupButtons { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; + } + + .help-link-container { + margin-top: 8px; + text-align: right; + } + + .grade-help-link { + color: var(--neon-blue); + text-decoration: underline; + cursor: pointer; + font-size: 0.85rem; + opacity: 0.8; + transition: color 0.2s; + } + + .grade-help-link:hover { + color: var(--neon-green); + opacity: 1; + } + + .popup-content strong { + display: block; + color: var(--neon-blue); + font-size: 1.1rem; + margin-bottom: 0.7rem; + } + + .popup-content p { + margin: 0 0 1.2rem 0; + font-size: 1rem; + color: var(--text-color); + } + + .popup-btn-default { + background: var(--neon-blue); + color: #000; + border: none; + border-radius: 5px; + padding: 0.5rem 1.2rem; + font-family: "Orbitron", sans-serif; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + box-shadow: 0 0 10px var(--neon-blue); + transition: background 0.2s, color 0.2s; + } + + .popup-btn-default:hover { + background: var(--neon-green); + color: #000; + } + + /* ========================================= + 8. States & Utilities + ========================================= */ + .link-orange { + color: #ff9d00 !important; + } + + .link-orange:hover { + color: #ffb74d !important; + text-shadow: 0 0 8px rgba(255, 157, 0, 0.4); + } + + #loading { + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + justify-content: center; + align-items: center; + flex-direction: column; + z-index: 10; + border-radius: 10px; + } + + #statusMsg { + text-align: center; + width: 100%; + margin-top: 15px; + color: white; + font-family: "Orbitron", sans-serif; + line-height: 1.5; + } + + .spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid var(--neon-blue); + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } + \ No newline at end of file