Gregory Huxley 1 semana atrás
pai
commit
3b89011b89

+ 135 - 299
TK2.php

@@ -1,307 +1,164 @@
 <?php
 
+/**
+ * TimeKeeper front page controller: shows the student's stats, checks a
+ * submitted answer, and asks a question generator for the next question.
+ *
+ * Changes from the current repo version:
+ *  - removed adminDev(): for every Main Admin it ran on every page load,
+ *    printed debug dumps (the students' settings and stats, test numbers),
+ *    hard-coded two students' user names, set the time zone, and generated a
+ *    question from the unfinished "continents" generator
+ *  - checkAnswer() called delete(..., true): the third argument is e107's
+ *    debug flag, so the SQL was printed on the page after every answer.
+ *    It's gone, and the user name is escaped
+ *  - generators are loaded with require_once and an absolute path (include
+ *    plus ./relative paths depended on the working directory and could
+ *    redeclare a class)
+ *  - getQuestion(): generalKnowledge fell through into elements (no break);
+ *    countries/flags/capitalCities built an elements2 object after including
+ *    continents.php (class not found). Subjects that aren't built yet now
+ *    show a plain message instead
+ *  - getScores()/getLastQuestion(): MPREFIX instead of `e107.e107_...`, user
+ *    escaped, and a student with no answers this month gets zeros (not
+ *    null) for the stats
+ *  - showMessage(): user name escaped
+ *  - someone with neither the student nor the teacher class now sees a
+ *    message instead of an empty page
+ */
 require_once __DIR__ . '/TkAnswerStore.php';
 
 class TK2 {
-    
+
     private $data = [];
     private $subjectsList = [
         // Math
-            [['numbers',1,0],// number sequences
-            ['addition',1,10000],// Numeracy adding
-            ['subtraction',1,20000],// Numeracy minus'
-            ['multiplication',1,200000],// Numeracy times'
-            ['division',0,0],// Numeracy deviding
-            ['rounding',0,0],// Numeracy rounding
-            ['worded_add',0,0],// Numeracy worded adding
-            ['worded_sub',0,0],// Numeracy worded minusing
-            ['worded_mult',0,0],// Numeracy worded timesing
-            ['worded_devi',0,0],// Numeracy worded deviding
-            ],
+        [['numbers', 1, 0], // number sequences
+            ['addition', 1, 10000], // Numeracy adding
+            ['subtraction', 1, 20000], // Numeracy minus'
+            ['multiplication', 1, 200000], // Numeracy times'
+            ['division', 0, 0], // Numeracy deviding
+            ['rounding', 0, 0], // Numeracy rounding
+            ['worded_add', 0, 0], // Numeracy worded adding
+            ['worded_sub', 0, 0], // Numeracy worded minusing
+            ['worded_mult', 0, 0], // Numeracy worded timesing
+            ['worded_devi', 0, 0], // Numeracy worded deviding
+        ],
         // English
-            [['spelling',1,0],// Plain spelling
-            ['anagrams',1,4000000],// Shuffle letters in a word 
-            ],
+        [['spelling', 1, 0], // Plain spelling
+            ['anagrams', 1, 4000000], // Shuffle letters in a word
+        ],
         // General knowledge
-            [['generalKnowledge',0,0],// Name kinda says it all
-            ],
+        [['generalKnowledge', 0, 0], // Name kinda says it all
+        ],
         // Science
-            [['elements',1,150000],// Periodic table of elements
-            ],
+        [['elements', 1, 150000], // Periodic table of elements
+        ],
         // Geography
-            [['continents',0,0],// Point to the continent
-            ['countries',0,0],// Point to the country
-            ['flags',0,0],// Name the country by the flag picture
-            ['capitalCities',0,0],// Name the capital for a country
-            ],
+        [['continents', 0, 0], // Point to the continent
+            ['countries', 0, 0], // Point to the country
+            ['flags', 0, 0], // Name the country by the flag picture
+            ['capitalCities', 0, 0], // Name the capital for a country
+        ],
         // History
-            
         // Art
-            [['artists',0,0],// Name the artist for famous paintings
-            ],
+        [['artists', 0, 0], // Name the artist for famous paintings
+        ],
         // Technology
-            
-        
         // language
-            [['malaySpelling',0,0],// This could be interesting
-            ],
+        [['malaySpelling', 0, 0], // This could be interesting
+        ],
         // Health - Health and Physical Education
-            [['nameBodyParts',0,0],// Name the body parts from the images or point to the part
-            ['nameBodyOrgans',0,0],// Name the organs or what they do
-            ['basicFirstAid',0,0],// Some BASIC things to know for first aid
-            ],
+        [['nameBodyParts', 0, 0], // Name the body parts from the images or point to the part
+            ['nameBodyOrgans', 0, 0], // Name the organs or what they do
+            ['basicFirstAid', 0, 0], // Some BASIC things to know for first aid
+        ],
     ];
 
     /**
      * Run the timekeeper functionality
      */
     public function run(): void {
-        if (check_class("TIMEKEEPER_STUDENT")) {
+        $isStudent = check_class("TIMEKEEPER_STUDENT");
+        $isTeacher = check_class("TIMEKEEPER_TEACHER");
+
+        if ($isStudent) {
             $this->student();
         }
-        if (check_class("TIMEKEEPER_TEACHER")) {
+        if ($isTeacher) {
             $this->teacher();
         }
-        if (check_class("Main Admin")) {
-            $this->adminDev();
+        if (!$isStudent && !$isTeacher) {
+            e107::getRender()->tablerender('TimeKeeper', USER ? 'Your account has no TimeKeeper role (student or teacher).' : 'Please log in to use TimeKeeper.');
         }
     }
-    
-    /**
-     * Run the sequence for admin/developer
-     */
-    /**
-     * Run the sequence for admin/developer
-     */
-    private function adminDev(): void {
-        e107::getRender()->tablerender('Developer', '=================================================================');
-        
-        ///////////////////////
-        //// testing !!!!!!!
-        ///////////////////////
-        $sign = 'X';
-        $difficulty = [
-            'count' => 64,
-            'percent' => 75
-        ];
-        if($sign=='X'||$sign=='/')
-        {
-            $difficulty['count'] = (int)$difficulty['count'] / 4;
-            $difficulty['percent'] = (int)$difficulty['percent'] / 6;
-        }
-        
-        if($difficulty['count'] >= 300) {
-            $startingNumber = mt_rand(0, (int) pow($difficulty['count'], ($difficulty['percent']/100)*2));
-            $endingNumber = mt_rand(0, (int) pow($difficulty['count'], ($difficulty['percent']/100)*2));
-            $returnNumbers = [$startingNumber, $endingNumber];
-        } elseif($difficulty['count'] >= 200) {
-            $startingNumber = mt_rand(0, (int) $difficulty['count'] + ($difficulty['percent']/100)*2);
-            $endingNumber = mt_rand(0, (int) $difficulty['count'] + ($difficulty['percent']/100)*2);
-            $returnNumbers = [$startingNumber, $endingNumber];
-        } elseif($difficulty['count'] >= 100) {
-            $startingNumber = mt_rand(0, (int) ($difficulty['percent'])*2);
-            $endingNumber = mt_rand(0, (int) ($difficulty['percent'])*2);
-            $returnNumbers = [$startingNumber, $endingNumber];
-        } else {
-            $startingNumber = mt_rand(0, $difficulty['count'] + 1);
-            $endingNumber = mt_rand(0, $difficulty['count'] + 1);
-            $returnNumbers = [$startingNumber, $endingNumber];
-        }
-        print_a(['testingData'=>['$sign'=>$sign,'$difficulty[\'count\']'=>$difficulty['count'],'$difficulty[\'percent\']'=>$difficulty['percent'],'$startingNumber'=>$startingNumber,'$endingNumber'=>$endingNumber,'$returnNumbers'=>$returnNumbers]]);
-        
-        ///////////////////////
-        //// Overrides !!!!!!!
-        ///////////////////////
-        $this->data['user'] = 'silas';
-        
-        $this->getScores($this->data['user']);
-        $this->showMessage();
-        $this->data['user'] = 'silas';//'gregory';
-        $this->getScores($this->data['user']);
-        $this->showMessage();
-        $this->data['user'] = 'elliot';//'gregory';
-        $this->getScores($this->data['user']);
-        $this->showMessage();
-        
-        // =====================================================================
-        // =========== Testing area ============================================
-        // =====================================================================
-        //$difficulty  = ['count'=>22,'percent'=>63.6364]; // Silas add
-        //$difficulty  = ['count'=>213,'percent'=>99.0610]; // Elliot add
-        //$difficulty  = ['count'=>21,'percent'=>33.3333]; // Silas sub
-        //$difficulty  = ['count'=>207,'percent'=>97.5845]; // Elliot sub
-        //$difficulty  = ['count'=>0,'percent'=>null]; // Silas mult
-        $difficulty  = ['count'=>201,'percent'=>98.5075]; // Elliot mult
-        
-        
-            $startingNumber4 = mt_rand(0, (int) pow($difficulty['count'], ($difficulty['percent']/100)*2));
-            $endingNumber4 = mt_rand(0, (int) pow($difficulty['count'], ($difficulty['percent']/100)*2));
-            
-            $startingNumber3 = mt_rand(0, (int) $difficulty['count'] + ($difficulty['percent']/50)*2);
-            $endingNumber3 = mt_rand(0, (int) $difficulty['count'] + ($difficulty['percent']/50)*2);
-            
-            $startingNumber2 = mt_rand(0, (int) ($difficulty['percent']/10)*4);
-            $endingNumber2 = mt_rand(0, (int) ($difficulty['percent']/10)*4);
-            
-            $startingNumber1 = mt_rand(0, $difficulty['count'] + 5);
-            $endingNumber1 = mt_rand(0, 10);
-        
-            
-            print_a([['startingNumber1'=>$startingNumber1,'endingNumber1'=>$endingNumber1],
-                ['startingNumber2'=>$startingNumber2,'endingNumber2'=>$endingNumber2],
-                ['startingNumber3'=>$startingNumber3,'endingNumber3'=>$endingNumber3],
-                ['startingNumber4'=>$startingNumber4,'endingNumber4'=>$endingNumber4],]);
-        // =====================================================================
-        // =========== End Testing area ========================================
-        // =====================================================================
-            
-        //$this->checkAnswer();
-        
-      
-        // If the last question has not been answered ask again.
-//        if($this->getLastQuestion()){
-//            return;
-//        }
-
-
-        //$this->data['selectedSubject'] = 'addition';
-        //$this->getQuestion();
-
-        //$this->data['selectedSubject'] = 'subtraction';
-        //$this->getQuestion();
-        
-        //$this->data['selectedSubject'] = 'multiplication';
-        $this->data['selectedSubject'] = 'continents';
-        
-        $this->getQuestion();
-        
-        //$this->data['selectedSubject'] = 'division';
-        //$this->getQuestion();
-        
-        //$this->data['selectedSubject'] = 'rounding';
-        //$this->getQuestion();
-        
-        date_default_timezone_set('Australia/Perth');
-        $QuestionsAnswered = e107::getDb()->retrieve('tkReport', 'COUNT(id)', 'username="elliot" '
-                    . 'AND answereddatestamp >= "' . mktime(0, 0, 0, date('n'), date('j')) . '" '
-                    . 'AND answereddatestamp <= "' . mktime(23, 59, 59, date('n'), date('j')) . '"');
-        $settings = e107::getDb()->retrieve('timekeeper', '*', 'username="elliot"');
-        print_a(['ElliotsCounter'=>$QuestionsAnswered,'ElliotsSettings'=>$settings]);
-        
-        //print_a($this->subjectsList);
-        print_a($this->data);
-        
-        $stats = e107::getDb()->retrieve("SELECT (SUM(correct)/COUNT(correct)*100) as percent,COUNT(correct) as count
-            FROM e107.`e107_tkReport` WHERE 
-            answereddatestamp >= unix_timestamp((NOW()) - INTERVAL 1 MONTH)
-            AND username = 'elliot' AND subject = 'multiplication';");
-        print_a($stats);
 
-// Look inside data bases
-if(false)
-{
-    //e107_timekeeper
-    //e107_timekeeperPending
-    //e107_timekeeperQuestions
-    //e107_timekeeperReport
-    //e107_timekeeperScore
-    //e107_timekeeperStatistics
-    //e107_timekeeperStats
-    //e107_tkLastQuestion
-    //e107_tkReport
-    //e107_tk_wordList
-        
-        //e107::getDb()->gen("SHOW TABLES;");
-        e107::getDb()->gen("SHOW COLUMNS FROM e107_tkReport;");
-        
-        while ($row = e107::getDb()->fetch()) {
-                
-            print_a( $row );
-        
-        }
-}
-        
-        e107::getRender()->tablerender('Developer.', '=================================================================');
-    }
-    
-    
-    /**
-     * Run the sequence for teachers
-     */
     /**
      * Run the sequence for teachers
      */
     private function teacher(): void {
-        
+
     }
-    
-    /**
-     * Run the sequence for students
-     */
+
     /**
      * Run the sequence for students
      */
     private function student(): void {
-        
-        $this->checkAnswer(e107::getUser()->getLoginName());
-        
-        $this->getScores(e107::getUser()->getLoginName());
+        $user = e107::getUser()->getLoginName();
+
+        $this->checkAnswer($user);
+
+        $this->getScores($user);
         $this->showMessage();
-        
+
         // If the last question has not been answered ask again.
-        if($this->getLastQuestion()){
+        if ($this->getLastQuestion()) {
             return;
         }
-        
+
         $this->getSubject();
         $this->getQuestion();
     }
-    
-    /**
-     * Check if the last question was answered and check the answer
-     */
+
     /**
      * Check if the last question was answered and check the answer
      * @param string $user
      */
     private function checkAnswer($user): void {
-        $subject = filter_input(INPUT_POST,'subject');
-        if(isset($subject)){
-            include './answerChecker2.php';
+        $subject = filter_input(INPUT_POST, 'subject');
+        if (is_string($subject) && $subject !== '') {
+            require_once __DIR__ . '/answerChecker2.php';
             $ac = new answerChecker2();
-            $ac->checkQuestionSubject($subject,$user);
-            e107::getDb()->delete('tkLastQuestion',"user = '".$user."'",true);
+            $ac->checkQuestionSubject($subject, $user);
+
+            $db = e107::getDb();
+            $db->delete('tkLastQuestion', "user = '" . $db->escape($user) . "'");
         }
     }
-    
-    /**
-     * Get the the last question asked if available
-     */
+
     /**
-     * Get the last question asked if available
-     * @return bool
+     * Show the last question asked if it hasn't been answered.
+     * @return bool True when a question was shown.
      */
     private function getLastQuestion(): bool {
-        $lastQuestion = e107::getDb()->retrieve("SELECT LastQuestionHtml FROM e107.`e107_tkLastQuestion` WHERE user = '".$this->data['user']."';");
-        //print_a(base64_decode($lastQuestion));
-        if(isset($lastQuestion)){
+        $db = e107::getDb();
+        $lastQuestion = $db->retrieve("SELECT LastQuestionHtml FROM `" . MPREFIX . "tkLastQuestion` WHERE user = '" . $db->escape($this->data['user']) . "' ORDER BY id DESC LIMIT 1");
+
+        if (!empty($lastQuestion)) {
             e107::getRender()->tablerender('Last Question', base64_decode($lastQuestion));
             return true;
         }
         return false;
     }
-    
-    /**
-     * Get the list of available subjects and select a random one.
-     */
+
     /**
      * Get the question based on selected subject
      * @return void
      */
     private function getQuestion(): void {
         switch ($this->data['selectedSubject']) {
-            case 'numbers':                
-                include './questionGenerators/math/numbers2.php';
+            case 'numbers':
+                require_once __DIR__ . '/questionGenerators/math/numbers2.php';
                 $questionGen = new numbers2($this->data['user']);
                 $questionGen->getNumbers();
                 break;
@@ -310,8 +167,8 @@ if(false)
             case 'multiplication':
             case 'division':
             case 'rounding':
-                include './questionGenerators/math/numeracy2.php';
-                $questionGen = new numeracy2($this->data['user'],$this->data['selectedSubject']);
+                require_once __DIR__ . '/questionGenerators/math/numeracy2.php';
+                $questionGen = new numeracy2($this->data['user'], $this->data['selectedSubject']);
                 $questionGen->getNumeracy();
                 break;
             case 'worded_add':
@@ -319,115 +176,94 @@ if(false)
             case 'worded_mult':
             case 'worded_devi':
             case 'spelling':
-                include './questionGenerators/english/spelling2.php';
+                require_once __DIR__ . '/questionGenerators/english/spelling2.php';
                 $questionGen = new spelling2($this->data['user']);
                 $questionGen->getSpellingWord();
                 break;
             case 'anagrams':
-                include './questionGenerators/english/anagrams.php';
+                require_once __DIR__ . '/questionGenerators/english/anagrams.php';
                 $questionGen = new anagrams($this->data['user']);
                 $questionGen->getanagram();
                 break;
-            case 'generalKnowledge':
-                include './questionGenerators/general/generalKnowledge.php';//=========================== TODO:not working yet
-                $questionGen = new generalKnowledge($this->data['user']);
-                $questionGen->getgeneralKnowledge();
             case 'elements':
-                include './questionGenerators/science/elements2.php';
+                require_once __DIR__ . '/questionGenerators/science/elements2.php';
                 $questionGen = new elements2($this->data['user']);
                 $questionGen->getElements();
                 break;
+            case 'generalKnowledge':
             case 'continents':
-                include './questionGenerators/geography/continents.php';//=========================== TODO:not working yet
-                $questionGen = new continents($this->data['user']);
-                $questionGen->getContinents();
-                break;
             case 'countries':
-                include './questionGenerators/geography/continents.php';//=========================== TODO:not working yet
-                $questionGen = new elements2($this->data['user']);
-                $questionGen->getElements();
-                break;
             case 'flags':
-                include './questionGenerators/geography/continents.php';//=========================== TODO:not working yet
-                $questionGen = new elements2($this->data['user']);
-                $questionGen->getElements();
-                break;
             case 'capitalCities':
-                include './questionGenerators/geography/continents.php';//=========================== TODO:not working yet
-                $questionGen = new elements2($this->data['user']);
-                $questionGen->getElements();
-                break;
             case 'artists':
             case 'malaySpelling':
             case 'nameBodyParts':
             case 'nameBodyOrgans':
             case 'basicFirstAid':
-                
             default:
-                print_a('An error has been detected in the question selector. Please report this and refresh the page.');
+                // Not built yet (or unknown): none of these are in the random pool.
+                e107::getRender()->tablerender('TimeKeeper', 'This subject is not available yet. Please refresh the page.');
                 break;
         }
     }
-    
-    /**
-     * Get the list of available subjects and select a random one.
-     */
+
     /**
      * Get the list of available subjects and select a random one.
      * @return void
      */
     private function getSubject(): void {
         $usableSubjects = [];
-        foreach ($this->subjectsList as $subjects){
-            foreach ($subjects as $subject){
-                if($subject[1]===1&&$this->data['difficulty']>=$subject[2]){
+        foreach ($this->subjectsList as $subjects) {
+            foreach ($subjects as $subject) {
+                if ($subject[1] === 1 && $this->data['difficulty'] >= $subject[2]) {
                     $usableSubjects[] = $subject[0];
                 }
             }
         }
-        $this->data['selectedSubject'] = $usableSubjects[mt_rand('0',count($usableSubjects)-1)];
+
+        if (empty($usableSubjects)) {
+            $usableSubjects[] = 'numbers';
+        }
+
+        $this->data['selectedSubject'] = $usableSubjects[mt_rand(0, count($usableSubjects) - 1)];
     }
-    
-    /**
-     * Show an initial stats message to the user.
-     */
+
     /**
      * Show an initial stats message to the user.
      * @return void
      */
     private function showMessage(): void {
-        $ns = e107::getRender();
-        $text = "You have answered ".$this->data['cntr']." questions this month.<br/>"
-                . "Your accuacy for the month is ".$this->data['percent']."%.<br/>"
-                . "Your difficulty is set at ".$this->data['difficulty'].".<br/><br/>";
-        
-        $ns->tablerender('Your stats '.ucfirst($this->data['user']), $text);
+        $text = "You have answered " . (int) $this->data['cntr'] . " questions this month.<br/>"
+                . "Your accuacy for the month is " . htmlspecialchars((string) $this->data['percent']) . "%.<br/>"
+                . "Your difficulty is set at " . htmlspecialchars((string) $this->data['difficulty']) . ".<br/><br/>";
+
+        e107::getRender()->tablerender('Your stats ' . htmlspecialchars(ucfirst($this->data['user'])), $text);
     }
-    
-    /**
-     * Get the initial values needed to generate a sane question.
-     * @param type $user
-     */
+
     /**
      * Get the initial values needed to generate a sane question.
      * @param string $user
      * @return void
      */
     private function getScores($user): void {
+        $db = e107::getDb();
+        $u = $db->escape($user);
+
         $returnArray = [];
-        $returnArray['totalQuestionCount'] = e107::getDb()->count("tkReport", "(id)", "username = '" . $user . "'");
-        e107::getDb()->gen("SELECT COUNT(id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '" . $user . "'
-            AND answereddatestamp >= unix_timestamp(CURRENT_DATE() - INTERVAL 1 MONTH)
-            AND answereddatestamp <= unix_timestamp(CURRENT_DATE() + INTERVAL 1 DAY)");
-            while ($row = e107::getDb()->fetch()) {
-                $returnArray = array_merge($returnArray,$row);
-            }
-        $returnArray['user'] = $user;    
-        $returnArray['difficulty'] = $returnArray['totalQuestionCount']*$returnArray['percent'];
-        
+        $returnArray['totalQuestionCount'] = (int) $db->count('tkReport', '(id)', "username = '{$u}'");
+
+        $db->gen("SELECT COUNT(id) AS cntr, CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+            FROM `" . MPREFIX . "tkReport` WHERE username = '{$u}'
+            AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE() - INTERVAL 1 MONTH)
+            AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)");
+        $row = $db->fetch();
+
+        $returnArray['cntr'] = is_array($row) ? (int) $row['cntr'] : 0;
+        $returnArray['percent'] = (is_array($row) && $row['percent'] !== null) ? (float) $row['percent'] : 0;
+        $returnArray['user'] = $user;
+        $returnArray['difficulty'] = $returnArray['totalQuestionCount'] * $returnArray['percent'];
+
         $this->data = $returnArray;
-        
     }
-    
+
 }

+ 10 - 6
answerChecker2.php

@@ -18,6 +18,7 @@
  *    increments keys on false or decrements null; user escaped in SQL
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
+ *  - a wrong answer takes a minute off but never below zero
  */
 class answerChecker2 {
 
@@ -116,7 +117,8 @@ class answerChecker2 {
             return;
         }
 
-        $correct = $this->isCorrect($answer['answer1'], $usersAnswer) || $this->isValidAnagram($usersAnswer, $answer['answer1']);
+        $correct = $this->isCorrect($answer['answer1'], $usersAnswer)
+                || $this->isValidAnagram($usersAnswer, $answer['answer1']);
 
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
     }
@@ -158,9 +160,9 @@ class answerChecker2 {
         $messages = $this->messages[$subject];
 
         $safe = [
-            '{ANSWER}' => htmlspecialchars($expected),
+            '{ANSWER}'      => htmlspecialchars($expected),
             '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
-            '{QUESTION}' => htmlspecialchars($question),
+            '{QUESTION}'    => htmlspecialchars($question),
         ];
 
         $text = strtr($correct ? $messages[1] : $messages[2], $safe) . "<br/>";
@@ -243,7 +245,9 @@ class answerChecker2 {
             $currentScore['timekeeper_lifetimecorrect']++;
             $currentScore['timekeeper_minutes'] = $currentScore['timekeeper_minutes'] + $this->scaleTimeAddition();
         } else {
-            $currentScore['timekeeper_minutes']--;
+            // Never below zero: the column is signed now, and a negative balance would
+            // otherwise have to be earned back before any play time counts.
+            $currentScore['timekeeper_minutes'] = max(0, $currentScore['timekeeper_minutes'] - 1);
         }
         $currentScore['timekeeper_lifetimequestions']++;
 
@@ -315,8 +319,8 @@ class answerChecker2 {
         $answer = TkAnswerStore::consume($this->user, is_string($token) ? $token : null);
         if ($answer === null) {
             e107::getRender()->tablerender('Answer check',
-                    'That question has expired or was already answered. Please answer the new question.');
+                'That question has expired or was already answered. Please answer the new question.');
         }
         return $answer;
     }
-}
+}

+ 33 - 19
localUpdater.php

@@ -1,24 +1,38 @@
 <?php
 
-/* 
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
+/**
+ * Serves the current client script (timekeeperLocal.php) for the students'
+ * machines to download:
+ *
+ *   wget -O .timekeeper.php "https://overlord.pfip.au/e107_plugins/timekeeper/localUpdater.php"
+ *
+ * Changes from the current repo version:
+ *  - ob_clean() raised a notice when no output buffer was active; if
+ *    display_errors was on, that text was prepended to the download and
+ *    pushed the "#!/bin/php" line off line 1 (so the script could no longer
+ *    be run directly). It only cleans a buffer that exists now
+ *  - the file is found next to this script, not relative to the working directory
+ *  - a missing file now answers 404 instead of an empty 200
+ *  - Cache-Control: no-store, so clients always get the newest version
  */
+$file = __DIR__ . '/timekeeperLocal.php';
 
-$file = 'timekeeperLocal.php';
-
-if (file_exists($file)) {
-    header('Content-Description: File Transfer');
-    header('Content-Type: application/octet-stream');
-    header('Content-Disposition: attachment; filename='.basename($file));
-    header('Content-Transfer-Encoding: binary');
-    header('Expires: 0');
-    header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
-    header('Pragma: public');
-    header('Content-Length: ' . filesize($file));
-    ob_clean();
-    flush();
-    readfile($file);
+if (!is_file($file)) {
+    http_response_code(404);
     exit;
-}
+}
+
+while (ob_get_level() > 0) {
+    ob_end_clean();
+}
+
+header('Content-Description: File Transfer');
+header('Content-Type: application/octet-stream');
+header('Content-Disposition: attachment; filename=' . basename($file));
+header('Content-Transfer-Encoding: binary');
+header('Expires: 0');
+header('Cache-Control: no-store, must-revalidate');
+header('Pragma: no-cache');
+header('Content-Length: ' . filesize($file));
+readfile($file);
+exit;

+ 32 - 17
plugin.xml

@@ -1,18 +1,33 @@
 <?xml version="1.0" encoding="utf-8"?>
-<e107Plugin name="TimeKeeper" lan="" version="1.0" date="2018-03-26" compatibility="2.0" installRequired="true">
-	<author name="Gregory J H" url="http://e107.org" />
-	<description lan="">An educational tool for teaching.</description>
-	<category>misc</category>
-	<adminLinks>
-		<link url='admin_prefs.php' description='LAN_CONFIGURE' icon='images/timer.128.png' iconSmall='images/timer.128.png' primary='true' >LAN_CONFIGURE</link>	
-	</adminLinks>
-	<siteLinks>
-		<link url="/e107_plugins/timekeeper/timekeeper.php">TimeKeeper</link>
-	</siteLinks>
-	<pluginPrefs>
-	</pluginPrefs>
-	<userClasses>
-		<class name="TimeKeeper_Teacher" description="TimeKeeper teacher. Able to add/edit and mark questions." />		
-		<class name="TimeKeeper_Student" description="TimeKeeper student. Able to earn credit by answering questions." />		
-	</userClasses>
-</e107Plugin>
+<e107Plugin name="TimeKeeper" lan="" version="1.1" date="2026-10-24" compatibility="2.0" installRequired="true">
+    <author name="Gregory J H" url="http://e107.org" />
+    <description lan="">An educational tool for teaching.</description>
+    <category>misc</category>
+    <adminLinks>
+        <link url='admin_prefs.php' description='LAN_CONFIGURE' icon='images/timer.128.png' iconSmall='images/timer.128.png' primary='true' >LAN_CONFIGURE</link>
+    </adminLinks>
+    <siteLinks>
+        <link url="/e107_plugins/timekeeper/timekeeper.php">TimeKeeper</link>
+    </siteLinks>
+    <pluginPrefs>
+        <pref name="stats_key"></pref>
+    </pluginPrefs>
+    <databaseTables>
+        <table>timekeeper</table>
+        <table>timekeeperPending</table>
+        <table>timekeeperQuestions</table>
+        <table>timekeeperReport</table>
+        <table>timekeeperScore</table>
+        <table>timekeeperStats</table>
+        <table>timekeeperStatistics</table>
+        <table>tkLastQuestion</table>
+        <table>tkPendingAnswer</table>
+        <table>tkReport</table>
+        <table>tk_wordList</table>
+        <table>tk_countries</table>
+    </databaseTables>
+    <userClasses>
+        <class name="TimeKeeper_Teacher" description="TimeKeeper teacher. Able to add/edit and mark questions." />
+        <class name="TimeKeeper_Student" description="TimeKeeper student. Able to earn credit by answering questions." />
+    </userClasses>
+</e107Plugin>

+ 1 - 1
timekeeperClientServices/elliotdebug.dat

@@ -1 +1 @@
--24-
+-21-

+ 1 - 1
timekeeperClientServices/silasdebug.dat

@@ -1 +1 @@
--25-
+-12-

+ 187 - 188
timekeeperClientServices/timer.php

@@ -1,12 +1,36 @@
 <?php
 
+/**
+ * Timekeeper timer endpoint: timekeeperClientServices/timer.php?user=<name>&game=<process>
+ *
+ * Called once a minute by timekeeperLocal.php on the student's machine. The
+ * reply is either the number of play minutes remaining (a number) or a
+ * message explaining why play isn't allowed (any non-number: the client
+ * shows it and closes the game).
+ *
+ * Changes from the current repo version:
+ *  - ?user= and ?game= were concatenated straight into SQL with no login
+ *    required (SQL injection), and ?user= was also used to build a file name
+ *    for debug output (arbitrary file write). Both are now validated against
+ *    a strict pattern and escaped, and the debug file is gone (that's what
+ *    the *debug.dat files in the repo were)
+ *  - unknown users are only auto-created if they have an e107 account (any
+ *    visitor could create rows by requesting ?user=anything)
+ *  - removed the leftover "reply 31 until 2026-05-13" override
+ *  - counters are updated with atomic UPDATE ... SET x = x + 1 instead of
+ *    writing back a value read earlier in the request
+ *  - when the free minutes run out the game was killed even if the student
+ *    had earned minutes: remaining free time of exactly 0 replied "0".
+ *    Earned minutes now take over, and the reply is 0 (never negative)
+ *    once there are none, so the client keeps closing the game
+ *  - new ?peek=1: reply with the minutes remaining WITHOUT using one up.
+ *    The client calls it when it first sees a game so the first minute is no
+ *    longer charged twice
+ *  - plain-text response header
+ */
 if (!defined('e107_INIT')) {
     require_once('../../../class2.php');
 }
-//print_r($_GET);
-
-$e107 = e107::getInstance();
-
 if (!e107::isInstalled('timekeeper')) {
     e107::redirect();
     exit;
@@ -14,7 +38,7 @@ if (!e107::isInstalled('timekeeper')) {
 
 /**
  * The Timekeeper's timer connection class.
- * 
+ *
  * This class checks if a user has the right to play games and if not states why.
  * This is an adapter to the local Timekeeper application monitor.
  *
@@ -22,299 +46,274 @@ if (!e107::isInstalled('timekeeper')) {
  */
 class timer {
 
-    private $user;
-    private $game;
-    private $debug;
+    private $user = null;
+    private $game = '';
+    private $invalid = false;
+    private $peek = false;
+    private $debug = false;
     private $sql;
     private $settings;
 
-    /**
-     * Constructor.
-     * Load $_GET variables via filters in to class variables ready for use
-     *     and instantiate the database.
-     */
     function __construct() {
-        $this->user = filter_input(INPUT_GET, 'user');
-        $this->game = filter_input(INPUT_GET, 'game');
-        $this->debug = filter_input(INPUT_GET, 'dbg');
+        $user = filter_input(INPUT_GET, 'user');
+        if (is_string($user) && preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $user)) {
+            $this->user = $user;
+        } else {
+            $this->invalid = true;
+        }
+
+        $game = filter_input(INPUT_GET, 'game');
+        if (is_string($game) && $game !== '') {
+            if (preg_match('/^[\w.+: ()\-]{1,100}$/', $game)) {
+                $this->game = $game;
+            } else {
+                $this->invalid = true;
+            }
+        }
+
+        $this->peek = filter_input(INPUT_GET, 'peek') ? true : false;
+        $this->debug = filter_input(INPUT_GET, 'dbg') ? true : false;
         $this->sql = e107::getDb();
         date_default_timezone_set('Australia/Perth');
     }
 
+    private function q($value) {
+        return $this->sql->escape($value);
+    }
+
+    private function debug($text) {
+        if ($this->debug) {
+            echo "Entered " . $text . "<br/>";
+        }
+    }
+
     /**
-     * Main entry point for this class.
-     * 
-     * This calls the following functions.
-     * <ul> 
-     * <li>@see getSettings();</li>
-     * <li>@see isTimekeeperOperable()</li>
-     * </ul>
-     * 
-     * @return void
+     * Main entry point.
      */
     public function main() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        
-        if($this->debug)
-        {
-            echo "<T1>Debug mode</T1><br/>";
+        if (!headers_sent()) {
+            header('Content-Type: text/plain; charset=utf-8');
+        }
+
+        if ($this->invalid || $this->user === null) {
+            echo 'Invalid request.';
+            return;
         }
-        
-        
-        if(strtotime('now')<=strtotime('2026-05-13 23:59:00')){echo 31;return;}
-        
-        
-//echo 31;return;
-//print_a($_SERVER);
-//echo 'Do your studies. NOW.';return;
-//echo 'Silas and Elliot to play toys.';return;
-//if ($this->game == "hl2_linux") {
-//    echo 'Half-life has been disabled.';return;
-//}
+
+        $this->debug(__FUNCTION__);
+
         // load the settings and init values from the database
         $this->getSettings();
 
-        // Check if user is allowed to play games if not reply 0 and exit now
+        // Check if user is allowed to play games, if not reply and exit now
         if (!$this->isTimekeeperOperable()) {
             return;
         }
 
-        //
         if (!$this->checkPlayTime()) {
             return;
         }
 
         // If a game is being played then update the statistics database
-        if ($this->game != "") {
+        if ($this->charging()) {
             $this->playTimeStatistics();
         }
     }
 
     /**
-     * This function loads the initial settings.
-     * 
-     * This function calls the database and loads the initial settings into
-     * the private class variable "$settings".
-     * 
-     * @return void
+     * Is this request supposed to use up a minute of play time?
      */
-    private function getSettings() 
-    {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        
-        $this->settings = $this->sql->retrieve('timekeeper', '*', 'username="' . $this->user . '"');
+    private function charging() {
+        return $this->game !== '' && !$this->peek;
+    }
+
+    private function getSettings() {
+        $this->debug(__FUNCTION__);
+        $this->settings = $this->sql->retrieve('timekeeper', '*', 'username="' . $this->q($this->user) . '"');
     }
 
     /**
-     * This function checks if the timekeeper should be operable.
-     * 
-     * This calls the following functions.
-     * <ul> 
-     * <li>@see playTimeWindow()</li>
-     * <li>@see minimumQuestionsMet()</li>
-     * </ul>
-     *  
+     * Should the timekeeper be operable for this user right now?
+     *
      * @return boolean
      */
     private function isTimekeeperOperable() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        
-        // Check the database reply for valid data
-        if (isset($this->settings['enabled'])) {
-            // is the current user allowed to play if not reply 0 and exit now
+        $this->debug(__FUNCTION__);
+
+        if (is_array($this->settings) && isset($this->settings['enabled'])) {
+            // timekeeper switched off for this user: no limit (reply 30 as before)
             if (!$this->settings['enabled']) {
-                //echo 'Timekeeper has been disabled.';
                 echo 30;
                 return false;
             }
         } else {
-            // Build the initial row for the database.
-            $query = ['username' => $this->user, 'enabled' => 1, 'freeMins' => 10, 'enableTime' => 10.00, 'disableTime' => 22.00, 'minQuestionCount' => 100, 'usedMinutes' => 0, 'lastDate' => 0, 'maxDailyPlayTime' => 1440];
+            // Only people with an e107 account get a settings row.
+            if ($this->sql->count('user', '(*)', 'user_loginname="' . $this->q($this->user) . '"') < 1) {
+                echo 'Unknown user.';
+                return false;
+            }
 
-            // commit the initial row to setup the user. 
+            $query = ['username' => $this->user, 'enabled' => 1, 'freeMins' => 10, 'enableTime' => 10.00, 'disableTime' => 22.00, 'minQuestionCount' => 100, 'usedMinutes' => 0, 'lastDate' => 0, 'maxDailyPlayTime' => 1440];
             $this->sql->insert('timekeeper', $query);
-            // let the user know that they have been added to the database.
-            echo $this->user . 'added to timekeepers database.';
+
+            echo $this->user . ' added to timekeepers database.';
             // exit here as the class variable would not be setup for the user.
             exit;
         }
 
-        // Check if user is within allowed play times if not reply 0 and exit now
+        // Check if user is within allowed play times
         if (!$this->playTimeWindow()) {
             echo 'You are not within allowed play times.';
             return false;
         }
-//print_r($this->settings);
+
         // Check has the user answered enough questions today.
         if (!$this->minimumQuestionsMet()) {
             if ($this->settings['freeMins'] >= 1) {
-                //echo 'You have ' . $this->settings['freeMins'] . " free minutes remaining.";
                 return true;
-            } else {
-                echo 'You have not answered enough questions to play.';
-                return false;
             }
+            echo 'You have not answered enough questions to play.';
+            return false;
         }
 
-        // if the previous tests passed then return true
         return true;
     }
 
     /**
-     * This function checks the time of day vs play time allowance.
-     * 
-     * This function checks if the play start time "enableTime" has been 
-     * met and the play stop time "disableTime" has not been exceeded and
-     * returns true if so. 
-     * 
-     * 
+     * Is the time of day within the play window?
+     *
      * @return boolean
      */
     private function playTimeWindow() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
+        $this->debug(__FUNCTION__);
 
-        // Check are the variable for the following if's avalible
         if (isset($this->settings['enableTime']) && isset($this->settings['disableTime'])) {
-
             // Disable time is less than enable time consider it the next day
-            if((double) date('H.i') <= (double) $this->settings['disableTime'] && (double) $this->settings['disableTime'] <= (double) $this->settings['enableTime']){
+            if ((double) date('H.i') <= (double) $this->settings['disableTime'] && (double) $this->settings['disableTime'] <= (double) $this->settings['enableTime']) {
                 return true;
             }
-            
             // Check if the enable time has been met, return false if not
             if ((double) date('H.i') <= (double) $this->settings['enableTime']) {
                 return false;
             }
-
-            // Check if the disable time has been exceeded, return false if so and don't do this check if the disable time is samller than enable time
+            // Check if the disable time has been exceeded (not when disable < enable)
             if ((double) date('H.i') >= (double) $this->settings['disableTime'] && !((double) $this->settings['disableTime'] <= (double) $this->settings['enableTime'])) {
                 return false;
             }
         }
 
-        // if the previous tests passed then return true
         return true;
     }
 
     /**
-     * This function checks if the user has answered enough questions today.
-     * 
-     * This function checks if the user has answered the minimum number
-     * of questions "minQuestionCount" today.
-     * 
-     * 
+     * Has the user answered at least minQuestionCount questions today?
+     *
      * @return boolean
      */
     private function minimumQuestionsMet() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        
-        // Check is the variable for the following if's avalible
+        $this->debug(__FUNCTION__);
+
         if (isset($this->settings['minQuestionCount'])) {
+            $answered = (int) $this->sql->retrieve('tkReport', 'COUNT(id)', 'username="' . $this->q($this->user) . '" '
+                    . 'AND answereddatestamp >= ' . (int) mktime(0, 0, 0, date('n'), date('j')) . ' '
+                    . 'AND answereddatestamp <= ' . (int) mktime(23, 59, 59, date('n'), date('j')));
 
-            // Count the number of questions that have been answered today and yesterday from the 'timekeeperReport' database table.
-            $QuestionsAnswered = $this->sql->retrieve('tkReport', 'COUNT(id)', 'username="' . $this->user . '" '
-                    . 'AND answereddatestamp >= "' . mktime(0, 0, 0, date('n'), date('j')) . '" '
-                    . 'AND answereddatestamp <= "' . mktime(23, 59, 59, date('n'), date('j')) . '"');
-            file_put_contents($this->user . 'debug.dat', '-' . $QuestionsAnswered . '-' . PHP_EOL);
-            // Check if the number of questions have been met, return false if not
-            if ($QuestionsAnswered < $this->settings['minQuestionCount']) {
+            if ($answered < (int) $this->settings['minQuestionCount']) {
                 return false;
             }
         }
 
-        // if the previous tests passed then return true
         return true;
     }
 
     /**
-     * This function checks the users play time restrictions.
-     * 
-     * This function checks the following.
-     * <ul>
-     * <li>The day for the counters and update the daily counter.</li>
-     * <li>The max play time counter and return false if exceeded.</li>
-     * <li>Whether there are any free play minutes remaining and if not use score minutes.</li>
-     * </ul>
-     * 
-     * @return boolean
+     * Work out the minutes remaining, and (unless peeking) use one up.
+     *
+     * Free minutes (freeMins minus today's usedMinutes) are used first; once
+     * they are gone the minutes earned from answering questions are used.
+     *
+     * @return boolean False when the user may not play now (reply already sent).
      */
     private function checkPlayTime() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        // Check are the variable for the following if's avalible
-        if (isset($this->settings['freeMins']) && isset($this->settings['usedMinutes']) && isset($this->settings['lastDate']) && isset($this->settings['maxDailyPlayTime'])) {
-
-            // Check if the database return date (month and day only) is the same
-            if (date('md', $this->settings['lastDate']) === date('md') && $this->game != "") {
-                // if so then add one minute to the 'usedMinutes' counter in the database
-                $this->sql->update('timekeeper', ['usedMinutes' => ($this->settings['usedMinutes'] + 1), 'WHERE' => 'username="' . $this->user . '"']);
-            } elseif ($this->game != "") {
-                // if not then update the 'lastDate' field to todays date and the 'usedMinutes' field to one in the database
-                $this->sql->update('timekeeper', ['lastDate' => time(), 'usedMinutes' => (1), 'WHERE' => 'username="' . $this->user . '"']);
-            }
+        $this->debug(__FUNCTION__);
 
-            // Check if the user has used more than their allowed play time for the day.
-            if ($this->settings['usedMinutes'] >= $this->settings['maxDailyPlayTime']) {
-                echo 'Max play time exceeded.';
-                return false;
-            }
+        $s = $this->settings;
+        if (!isset($s['freeMins']) || !isset($s['usedMinutes']) || !isset($s['lastDate']) || !isset($s['maxDailyPlayTime'])) {
+            return true;
+        }
 
-            $minutes = $this->settings['freeMins'] - $this->settings['usedMinutes'];
-            
-            // Check if the user has free play time and if so use it before erned minutes
-            if ($minutes < 0 ) {
+        // New day: today's counter starts again from zero.
+        $isToday = ((int) $s['lastDate'] > 0) && date('Ymd', (int) $s['lastDate']) === date('Ymd');
+        $used = $isToday ? (int) $s['usedMinutes'] : 0;
 
-                if (!$this->minimumQuestionsMet()) {
-                    echo 'You have not answered enough questions to play.';
-                    return false;
-                }
+        if ($used >= (int) $s['maxDailyPlayTime']) {
+            echo 'Max play time exceeded.';
+            return false;
+        }
 
-                // no free time left get minutes remaining from score.
-                $minutes = $this->sql->retrieve('timekeeperScore', 'timekeeper_minutes', 'timekeeper_username="' . $this->user . '"');
+        $free = (int) $s['freeMins'] - $used;
 
-                // minus one minute from the score counter 'timekeeper_minutes' and save back to database.
-                if ($this->game != "") {
-                    $this->sql->update('timekeeperScore', ['timekeeper_minutes' => ($minutes - 1), 'WHERE' => 'timekeeper_username="' . $this->user . '"']);
-                }
+        if ($free > 0) {
+            // Free play time left: use it before earned minutes.
+            $this->useMinute($isToday, false);
+            echo $free;
+            return true;
+        }
 
-                // report out the number of minutes remaining and return.
-                echo $minutes;
-                return true;
-            } else {
-                // report free time count and return.
-                //echo "You have " . $minutes . " free minutes of play remaining.";
-                echo $minutes;
-                return true;
-            }
+        // Free time is gone, so the questions-answered rule applies.
+        if (!$this->minimumQuestionsMet()) {
+            echo 'You have not answered enough questions to play.';
+            return false;
+        }
+
+        $earned = (int) $this->sql->retrieve('timekeeperScore', 'timekeeper_minutes', 'timekeeper_username="' . $this->q($this->user) . '"');
+        if ($earned <= 0) {
+            echo 0;
+            return false;
+        }
+
+        $this->useMinute($isToday, true);
+        echo $earned;
+        return true;
+    }
+
+    /**
+     * Use up one minute of play time (no-op when peeking or no game given).
+     *
+     * @param boolean $isToday   Whether the stored counter belongs to today.
+     * @param boolean $fromScore Also take the minute off the earned minutes.
+     */
+    private function useMinute($isToday, $fromScore) {
+        if (!$this->charging()) {
+            return;
+        }
+
+        $u = $this->q($this->user);
+
+        if ($isToday) {
+            $this->sql->gen("UPDATE `" . MPREFIX . "timekeeper` SET `usedMinutes` = `usedMinutes` + 1 WHERE `username` = '{$u}'");
+        } else {
+            $this->sql->gen("UPDATE `" . MPREFIX . "timekeeper` SET `lastDate` = " . (int) time() . ", `usedMinutes` = 1 WHERE `username` = '{$u}'");
+        }
+
+        if ($fromScore) {
+            $this->sql->gen("UPDATE `" . MPREFIX . "timekeeperScore` SET `timekeeper_minutes` = `timekeeper_minutes` - 1 WHERE `timekeeper_username` = '{$u}' AND `timekeeper_minutes` > 0");
         }
     }
 
     /**
-     * This function updates the statistics.
-     * 
-     * This function updates the statistics for games played and for how long.
-     * 
-     * @return void
+     * Update the statistics for games played and for how long.
      */
     private function playTimeStatistics() {
-        
-        if($this->debug){echo "Entered ". __FUNCTION__."<br/>";}
-        
-        // Get the timer minutes for the currently played game 
-        $playMinutes = $this->sql->retrieve('timekeeperStatistics', 'minutesPlayed', 'userName="' . $this->user . '" AND gameName="' . $this->game . '" AND playDate="' . mktime(12, 0, 0, date('n'), date('j')) . '"');
-
-        // if 'playMinutes' == zero then new game or user.
-        if ((int) $playMinutes === 0) {
-            // Add user and game to the database for tracking
-            $this->sql->insert('timekeeperStatistics', ['uniqueId' => 0, 'minutesPlayed' => ($playMinutes + 1), 'userName' => $this->user, 'gameName' => $this->game, 'playDate' => mktime(12, 0, 0, date('n'), date('j'))]);
+        $this->debug(__FUNCTION__);
+
+        $where = 'userName="' . $this->q($this->user) . '" AND gameName="' . $this->q($this->game) . '" AND playDate="' . (int) mktime(12, 0, 0, date('n'), date('j')) . '"';
+        $playMinutes = (int) $this->sql->retrieve('timekeeperStatistics', 'minutesPlayed', $where);
+
+        if ($playMinutes === 0) {
+            $this->sql->insert('timekeeperStatistics', ['uniqueId' => 0, 'minutesPlayed' => 1, 'userName' => $this->user, 'gameName' => $this->game, 'playDate' => mktime(12, 0, 0, date('n'), date('j'))]);
         } else {
-            // Update the current game and user played timer
-            $this->sql->update('timekeeperStatistics', ['minutesPlayed' => ($playMinutes + 1), 'WHERE' => 'userName="' . $this->user . '" AND gameName="' . $this->game . '" AND playDate="' . mktime(12, 0, 0, date('n'), date('j')) . '"']);
+            $this->sql->update('timekeeperStatistics', ['minutesPlayed' => ($playMinutes + 1), 'WHERE' => $where]);
         }
     }
 
@@ -322,4 +321,4 @@ class timer {
 
 //run the timer class as standalone
 $timerClass = new timer();
-$timerClass->main();
+$timerClass->main();

+ 228 - 155
timekeeperLocal.php

@@ -1,62 +1,47 @@
 #!/bin/php
-
 <?PHP
-
 // Local update and invoke command for bash.
 // kill $(pidof firefox) || wget -O .timekeeper.php "https://overlord.pfip.au/e107_plugins/timekeeper/localUpdater.php" && chmod +x ~/.timekeeper.php && ~/.timekeeper.php
-
-$version = "3.7";
-
-$TKL = new timekeeperLocal();
-
-if (isset($argv[1]) && $argv[1] == 'debug') {
-    echo "Debug mode active.";
-    $TKL->debug = true;
-    $TKL->message("Timekeeper " . $version, "Timekeeper is running. (In debug mode)");
-} else {
-    $TKL->message("Timekeeper " . $version, "Timekeeper is running.");
-}
-
-// mutex here
-echo 'Looking for other ' . $argv[0] . ' here.' . PHP_EOL;
-$firstCount = false;
-$cmdLineReturnArray = [];
-exec("ps aux | grep $(whoami) | grep " . $argv[0], $cmdLineReturnArray);
-foreach ($cmdLineReturnArray as $cmd) {
-    if (strpos($cmd, "/php")) {
-        if ($firstCount) {
-            die("Found another instance.");
-        }
-        $firstCount = true;
-    }
-}
-
-$gamesList = json_decode(file_get_contents('https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/games.json'), true);
-
-$TKL->user = exec("whoami");
-
-$TKL->Logging("Timekeeper " . $version . " initialised for " . $TKL->user, false);
-
-while (true) {
-
-    set_time_limit(70);
-
-    $startupTime = microtime(true);
-
-    $TKL->CheckForGames($gamesList);
-
-    $TKL->CheckTimeRemaining();
-
-    $TKL->burnTime($startupTime, 60);
-}
-
-exit;
+//
+// Changes from 3.7 (try it with "~/.timekeeper.php debug" first: debug mode
+// never kills anything, it only says what it would have done):
+//  - killing a game never worked: killRoutine() called count() on a string
+//    (fatal on PHP 8, and on PHP 7 it concluded the game was "supposedly
+//    dead" and returned without killing). Processes are now found once from
+//    `ps` output in PHP, and stopped with SIGTERM, then SIGKILL, then pkill
+//  - shell commands no longer splice text into a command line: game names
+//    from games.json, the user name and the notification text all go through
+//    escapeshellarg() (a server reply containing a quote or $( ) could run
+//    commands on this machine)
+//  - the first minute of a game was charged twice (once when the game was
+//    detected and again by the check right after); detection now asks the
+//    server with peek=1, which does not use up a minute
+//  - zero or fewer minutes (not only exactly zero) now closes the game
+//  - an unreachable server used to close the game at once, and
+//    file_get_contents() could hang for a minute. Requests time out after 10
+//    seconds and the game is only closed after MAX_FAILED_CHECKS failures in
+//    a row; set that to 1 for the old behaviour
+//  - the "another copy is running" check uses a lock file instead of
+//    grepping `ps`
+//  - the busy-wait in burnTime() is a single sleep
+//  - the log is capped at 1 MB and written next to the script
 
 class timekeeperLocal {
 
+    const VERSION = '3.8';
+    const BASE_URL = 'https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/';
+    const HTTP_TIMEOUT = 10;     // seconds to wait for the server
+    const MAX_FAILED_CHECKS = 5; // unreachable-server checks in a row before the game is closed
+    const MAX_LOG_BYTES = 1048576;
+
     public $lastActiveGame = '';
     public $user = '';
     public $debug = false;
+    private $failedChecks = 0;
+
+    public function __construct() {
+        $this->user = trim(exec('whoami'));
+    }
 
     public function test() {
         $this->message('test title', 'test message', '');
@@ -65,19 +50,16 @@ class timekeeperLocal {
     /**
      * Send a message to the user.
      *
-     * <pre><code>Example usage:
-     * $this->message('Alert','this is an important meassage.',1);
-     * </code></pre>
+     * Example usage:
+     * $this->message('Alert','this is an important message.',1);
      *
      * @param string $title The title to be displayed for the message.
      * @param string $messageText The body text of the message to be displayed.
-     * @param int $urgency The urgency level for the message to be displayed
-     * with. <br/>1 = High<br/>2 = Medium<br/>3 = Low
+     * @param int $urgency 1 = High, 2 = Medium, 3 = Low
      *
      * @return void
      */
     public function message($title, $messageText, $urgency = '') {
-
         switch ($urgency) {
             case 1:
                 $urgencyText = ' --urgency=critical';
@@ -88,56 +70,112 @@ class timekeeperLocal {
             case 3:
                 $urgencyText = ' --urgency=low';
                 break;
-
             default:
                 $urgencyText = '';
                 break;
         }
+        exec('notify-send' . $urgencyText . ' ' . escapeshellarg((string) $title) . ' ' . escapeshellarg(substr((string) $messageText, 0, 300)));
+    }
 
-        exec('notify-send' . $urgencyText . ' "' . $title . '" "' . $messageText . '"');
+    /**
+     * Ask the server. Returns the trimmed reply, or null if it can't be reached.
+     */
+    private function fetch($path) {
+        $context = stream_context_create(['http' => ['timeout' => self::HTTP_TIMEOUT]]);
+        $reply = @file_get_contents(self::BASE_URL . $path, false, $context);
+        return ($reply === false) ? null : trim($reply);
     }
 
-    public function burnTime($startupTime, $burnTimeDuration) {
+    private function timerUrl($game, $peek = false) {
+        return 'timer.php?user=' . rawurlencode($this->user) . '&game=' . rawurlencode($game) . ($peek ? '&peek=1' : '');
+    }
+
+    /**
+     * The list of game process names. Waits until the server answers.
+     */
+    public function loadGames() {
+        while (true) {
+            $json = $this->fetch('games.json');
+            $games = ($json === null) ? null : json_decode($json, true);
+            if (is_array($games)) {
+                return $games;
+            }
+            $this->Logging('Could not load games.json, trying again in 30 seconds.');
+            sleep(30);
+        }
+    }
+
+    /**
+     * All of this user's processes as [pid => command line], without this script.
+     */
+    private function processList() {
+        $lines = [];
+        exec('ps -u ' . escapeshellarg($this->user) . ' -o pid=,args=', $lines);
+
+        $self = getmypid();
+        $processes = [];
+        foreach ($lines as $line) {
+            if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $match)) {
+                continue;
+            }
+            if ((int) $match[1] === $self) {
+                continue;
+            }
+            $processes[(int) $match[1]] = $match[2];
+        }
+        return $processes;
+    }
 
+    /**
+     * Pids of the processes that belong to $game.
+     */
+    private function gamePids($game, $processes) {
+        $pids = [];
+        foreach ($processes as $pid => $commandLine) {
+            if (strpos($commandLine, $game) !== false && $this->cmdExceptions($commandLine)) {
+                $pids[] = $pid;
+            }
+        }
+        return $pids;
+    }
+
+    public function burnTime($startupTime, $burnTimeDuration) {
         if ($this->lastActiveGame == "") {
             sleep(20);
             return;
         }
 
-        //echo 'Burning ' . (int) (60 - (microtime(true) - $startupTime)) . ' seconds' . PHP_EOL;
-        //echo 'Burning from ' . microtime(true) . PHP_EOL;
-        while (microtime(true) < ($startupTime + $burnTimeDuration)) {
-
-            usleep(300);
+        $remaining = ($startupTime + $burnTimeDuration) - microtime(true);
+        if ($remaining > 0) {
+            usleep((int) ($remaining * 1000000));
         }
     }
 
     public function CheckForGames($listOfKnownGames) {
+        $processes = $this->processList();
 
         foreach ($listOfKnownGames as $gameName) {
-            $cmdLineReturnArray = [];
-            exec("ps aux | grep $(whoami) | grep $gameName", $cmdLineReturnArray); //print_r($cmdLineReturnArray);
-            foreach ($cmdLineReturnArray as $cmdLineReturn) {
-                if ($this->cmdExceptions($cmdLineReturn)) {
-                    $this->AnounceGameDetected($gameName);
-                    return;
-                }
+            if (!is_string($gameName) || $gameName === '') {
+                continue;
+            }
+            if (count($this->gamePids($gameName, $processes)) > 0) {
+                $this->AnounceGameDetected($gameName);
+                return;
             }
         }
         $this->AnounceGameNotDetected();
     }
 
     private function cmdExceptions($cmdLineReturn) {
-
-        if (strpos($cmdLineReturn, 'grep') != false) {
+        if (strpos($cmdLineReturn, 'grep') !== false) {
             return false;
-        } elseif (strpos($cmdLineReturn, 'kiosk') != false) {
+        } elseif (strpos($cmdLineReturn, 'kiosk') !== false) {
             return false;
-        } elseif (strpos($cmdLineReturn, 'contentproc') != false) {
+        } elseif (strpos($cmdLineReturn, 'contentproc') !== false) {
             return false;
-        } elseif (strpos($cmdLineReturn, 'new-window') != false) { //opera helper exception
+        } elseif (strpos($cmdLineReturn, 'new-window') !== false) { //opera helper exception
             return false;
-        } elseif (strpos($cmdLineReturn, 'type=gpu-process') != false) { // opera helper exception
+        } elseif (strpos($cmdLineReturn, 'type=gpu-process') !== false) { // opera helper exception
             return false;
         }
         return true;
@@ -146,113 +184,120 @@ class timekeeperLocal {
     private function AnounceGameDetected($game) {
         if ($this->lastActiveGame == "") {
             $this->lastActiveGame = $game;
-            $minutesRemaining = file_get_contents('https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/timer.php?user=' . $this->user . '&game=' . $this->lastActiveGame);
-            $this->message("Timekeeper", ucfirst($game) . " detected running." . PHP_EOL . "You have " . $minutesRemaining . " minutes remaining.");
-            $this->Logging(ucfirst($game) . " detected running. " . $minutesRemaining . " minutes remaining.");
+            $this->failedChecks = 0;
+
+            // peek=1: report the minutes remaining without using one up.
+            $reply = $this->fetch($this->timerUrl($game, true));
+
+            if ($reply !== null && is_numeric($reply)) {
+                $text = ucfirst($game) . " detected running." . PHP_EOL . "You have " . $reply . " minutes remaining.";
+            } elseif ($reply !== null) {
+                $text = ucfirst($game) . " detected running." . PHP_EOL . $reply;
+            } else {
+                $text = ucfirst($game) . " detected running." . PHP_EOL . "The Timekeeper server could not be reached.";
+            }
+
+            $this->message("Timekeeper", $text);
+            $this->Logging(ucfirst($game) . " detected running. " . ($reply === null ? 'No reply from server.' : $reply . ' minutes remaining.'));
         }
     }
 
     private function AnounceGameNotDetected() {
         if ($this->lastActiveGame != "") {
             $this->lastActiveGame = "";
+            $this->failedChecks = 0;
             $this->message("Timekeeper", "No games detected running.");
             $this->Logging("No games detected running.");
         }
-        //No games running so just read out the play time avalible
-        //echo file_get_contents('https://overlord.pfip.tk/e107_plugins/timekeeper/timekeeperClientServices/timer.php?user=' . $this->user . '&game=' . $this->lastActiveGame);
     }
 
     public function CheckTimeRemaining() {
-        if ($this->lastActiveGame != "") {
-            $serverResponse = file_get_contents('https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/timer.php?user=' . $this->user . '&game=' . $this->lastActiveGame);
-            //echo "==Debug==" . $serverResponse . "====";
-            $this->TimeLimitWarning($serverResponse);
+        if ($this->lastActiveGame == "") {
+            return;
+        }
+
+        $reply = $this->fetch($this->timerUrl($this->lastActiveGame));
+
+        if ($reply === null) {
+            $this->failedChecks++;
+            $this->Logging('Could not reach the Timekeeper server (' . $this->failedChecks . ' in a row).');
+            if ($this->failedChecks >= self::MAX_FAILED_CHECKS) {
+                $this->message("Timekeeper", "The Timekeeper server can not be reached, so the game will be closed.", 1);
+                $this->KillGame();
+            }
+            return;
         }
+
+        $this->failedChecks = 0;
+        $this->TimeLimitWarning($reply);
     }
 
     private function TimeLimitWarning($reply) {
-
         if (!is_numeric($reply)) {
+            // Anything that is not a number is the reason play is not allowed.
             $this->message("Timekeeper", $reply);
             $this->KillGame();
             return;
         }
 
-        switch ($reply) {
-            case 5:
-                $this->message("Timekeeper", "You only have 5 minutes remaining.");
-
-                break;
-
-            case 1:
-                $this->message("Timekeeper", "You only have 1 minute remaining.");
-
-                break;
-
-            case 0:
-                $this->message("Timekeeper", "Your play time has expired.");
-                $this->KillGame();
-                break;
-
-            default:
+        $minutes = (int) $reply;
 
-                break;
+        if ($minutes <= 0) {
+            $this->message("Timekeeper", "Your play time has expired.");
+            $this->KillGame();
+        } elseif ($minutes == 5) {
+            $this->message("Timekeeper", "You only have 5 minutes remaining.");
+        } elseif ($minutes == 1) {
+            $this->message("Timekeeper", "You only have 1 minute remaining.");
         }
     }
 
     private function KillGame() {
-        $gamePid = preg_split('/\s+/', exec("ps ax -f| grep $(whoami) | grep $this->lastActiveGame | grep -v grep | grep -v content"))[1];
-        if ($this->debug) {
-            $this->message("Timekeeper", "Game PID-" . $gamePid . " should\nhave just been killed.");
-        } else {            
-            for ($level = 0; $level < 3; $level++) {
-                $this->Logging('Killing '.$this->lastActiveGame . " with killRoutine($level).");
-                if($this->killRoutine($level))
-                {
-                    return;
-                }
-            }
+        $game = $this->lastActiveGame;
+        if ($game == "") {
+            return;
         }
-    }
 
-    private function killRoutine($level): bool{
+        $pids = $this->gamePids($game, $this->processList());
 
-        $gamePid = preg_split('/\s+/', exec("ps ax -f| grep $(whoami) | grep $this->lastActiveGame | grep -v grep | grep -v content"))[1];
-        
-        if(count($gamePid)>=2){
-            $this->Logging('Current pid '. $gamePid . ".");
-        }else{
+        if (count($pids) == 0) {
             $this->Logging('Game is supposedly dead, bailing out now.');
-            return true;
+            return;
         }
-        switch ($level) {
-            case 0:
-exec("kill -15 $gamePid");
-            break;
-            case 1:
-exec("kill -9 $gamePid");
-            break;
-            case 2:
-exec("pkill -15 $this->lastActiveGame");
-            break;
-            case 3:
-exec("pkill -9 $this->lastActiveGame");
-            break;
+
+        if ($this->debug) {
+            $this->message("Timekeeper", "Game PID-" . implode(',', $pids) . " should\nhave just been killed.");
+            return;
         }
-        sleep(10);
-        return false;
+
+        foreach ([15, 9] as $signal) {
+            $this->Logging('Killing ' . $game . ' (pid ' . implode(',', $pids) . ') with signal ' . $signal . '.');
+            foreach ($pids as $pid) {
+                $this->signal($pid, $signal);
+            }
+            sleep($signal === 15 ? 10 : 3);
+
+            $pids = $this->gamePids($game, $this->processList());
+            if (count($pids) == 0) {
+                return;
+            }
+        }
+
+        // Last resort.
+        $this->Logging('Still running, using pkill on ' . $game . '.');
+        exec('pkill -9 -u ' . escapeshellarg($this->user) . ' -f ' . escapeshellarg($game));
     }
 
+    private function signal($pid, $signal) {
+        if (function_exists('posix_kill')) {
+            @posix_kill((int) $pid, (int) $signal);
+        } else {
+            exec('kill -' . (int) $signal . ' ' . (int) $pid);
+        }
+    }
 
     /**
-     * 
-     * Logging($message,$append = true);
-     * 
-     * Write to the common log file.
-     *
-     * <pre><code>Example usage:
-     * $this->Logging('Hello this is a message.');
-     * </code></pre>
+     * Write to the common log file (next to this script, capped at 1 MB).
      *
      * @param string $message The message to be written into the log file.
      * @param bool $append Set to false to overwrite the log file.
@@ -260,15 +305,43 @@ exec("pkill -9 $this->lastActiveGame");
      * @return void
      */
     public function Logging($message, $append = true) {
-        try {
-            $filename = "timekeeper.log";
-            if ($append) {
-                file_put_contents($filename, $message . PHP_EOL, FILE_APPEND);
-            } else {
-                file_put_contents($filename, $message . PHP_EOL);
-            }
-        } catch (Exception $ex) {
-            $this->message("Error", "Logging can not be initialised." . PHP_EOL . $ex, 1);
+        $filename = __DIR__ . '/timekeeper.log';
+
+        if (is_file($filename) && filesize($filename) > self::MAX_LOG_BYTES) {
+            @rename($filename, $filename . '.1');
+        }
+
+        if (@file_put_contents($filename, $message . PHP_EOL, $append ? FILE_APPEND : 0) === false) {
+            $this->message("Error", "Logging can not be initialised.", 1);
         }
     }
+
+}
+
+$TKL = new timekeeperLocal();
+
+if (isset($argv[1]) && $argv[1] == 'debug') {
+    echo "Debug mode active.";
+    $TKL->debug = true;
+    $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running. (In debug mode)");
+} else {
+    $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running.");
+}
+
+// Only one copy per user: the lock goes away by itself when this script ends.
+$lock = fopen(sys_get_temp_dir() . '/timekeeper-' . preg_replace('/\W/', '_', $TKL->user) . '.lock', 'c');
+if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
+    die("Found another instance." . PHP_EOL);
 }
+
+set_time_limit(0);
+
+$gamesList = $TKL->loadGames();
+$TKL->Logging("Timekeeper " . timekeeperLocal::VERSION . " initialised for " . $TKL->user, false);
+
+while (true) {
+    $startupTime = microtime(true);
+    $TKL->CheckForGames($gamesList);
+    $TKL->CheckTimeRemaining();
+    $TKL->burnTime($startupTime, 60);
+}

+ 69 - 57
timekeeper_menu.php

@@ -1,76 +1,88 @@
 <?php
 
+/**
+ * TimeKeeper sidebar menu: the logged-in student's score per subject, today's
+ * question count and play minutes remaining.
+ *
+ * Changes from the current repo version:
+ *  - "You have N minutes remaining" read the score row with $this->user,
+ *    which doesn't exist in a menu file, so the minutes were always blank;
+ *    it uses the logged-in user name now
+ *  - one GROUP BY query replaces the hand-built UNION: a student with no
+ *    answers yet produced "SELECT * FROM ;" (invalid SQL) and undefined
+ *    variable notices
+ *  - a student with exactly one answer today now also gets the "games
+ *    enabled" line (the singular branch skipped it), and the unclosed <p>
+ *    tags are closed
+ *  - a student with no settings row no longer errors (uses the default
+ *    minimum of 100 questions that timer.php creates new rows with)
+ *  - user name escaped for SQL, subjects/values escaped for HTML,
+ *    `e107.e107_tkReport` -> MPREFIX
+ */
 if (!defined('e107_INIT')) {
     exit;
 }
-
 if (!e107::isInstalled('timekeeper')) {
     return '';
 }
 
+if (check_class("Main Admin") || check_class("Admin") || check_class("TIMEKEEPER_STUDENT")) {
 
-//e107::includeLan(e_PLUGIN."rss_menu/languages/".e_LANGUAGE."_admin_rss_menu.php");
+    $sql = e107::getDb();
+    $userName = e107::getUser()->getLoginName();
+    $u = $sql->escape($userName);
+    $reportTable = '`' . MPREFIX . 'tkReport`';
 
-if (check_class("Main Admin") || check_class("Admin") || check_class("TIMEKEEPER_STUDENT")) {
+    $settingsHolder = $sql->retrieve('timekeeper', '*', 'username="' . $u . '"');
+    $minQuestions = is_array($settingsHolder) ? (int) $settingsHolder['minQuestionCount'] : 100;
 
-        $sql = e107::getDb();
-        $userName = e107::getUser()->getLoginName();
+    // Score per subject, all time.
+    $sql->gen("SELECT `subject`, COUNT(id) AS `count`,
+            CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL(5,2)) AS percent
+        FROM $reportTable WHERE `username` = '$u' GROUP BY `subject` ORDER BY `subject`");
 
-        $settingsHolder = $sql->retrieve('timekeeper', '*', 'username="' . $userName . '"');
-        
-        $sql->gen("SELECT DISTINCT subject FROM e107_tkReport WHERE `username` = '$userName'");
+    $subjectRows = [];
+    while ($row = $sql->fetch()) {
+        $subjectRows[] = $row;
+    }
 
-        while ($row = $sql->fetch()) {
-            $subjects[] = $row['subject'];
-            
-        }
+    $text = "<table style='width:100%;text-align:center;'>"
+            . "<thead><tr><td style='text-align:left'>Subject</td><td>Score</td><td>No.Questions</td></tr></thead>"
+            . "<tbody>";
 
-//$sql->gen("SELECT * FROM (SELECT CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL (5 , 2 )) AS percent, COUNT(id) as `count`,
-//'Math' AS `subject` FROM e107_tkReport WHERE `username` = '$userName' 
-//AND (`subject` = 'numbers' OR `subject` = 'numeracy')) as subjects 
-//UNION 
-//(SELECT CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL (5 , 2 )) AS percent, COUNT(id) as `count`,'English' AS `subject`
-//FROM e107_tkReport WHERE `username` = '$userName' AND `subject` = 'spelling')");
-        $first = true;
-        foreach ($subjects as $subject) {
-            if($selects!=''){$selects .= ' UNION ';}
-        $selects .= "(SELECT CAST((SUM(correct) / COUNT(id) * 100) AS DECIMAL (5 , 2 )) AS percent, COUNT(id) as `count`,'".ucfirst($subject)."' AS `subject`
-            FROM e107_tkReport WHERE `username` = '$userName' AND `subject` = '$subject')";
-            if($first){
-                $selects .= " as subjects";
-                $first = false;
-            }
-        }
+    foreach ($subjectRows as $row) {
+        $text .= "<tr><td style='text-align:left'>" . htmlspecialchars(ucfirst($row['subject']), ENT_QUOTES, 'UTF-8') . '</td><td>' . htmlspecialchars($row['percent']) . '%</td><td>' . (int) $row['count'] . '</td></tr>';
+    }
+
+    $text .= '</tbody></table>';
 
-        $sql->gen("SELECT * FROM $selects;");
+    // Today.
+    $sql->gen("SELECT COUNT(id) AS cntr, CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
+        FROM $reportTable WHERE username = '$u'
+        AND answereddatestamp >= UNIX_TIMESTAMP(CURRENT_DATE())
+        AND answereddatestamp <= UNIX_TIMESTAMP(CURRENT_DATE() + INTERVAL 1 DAY)");
 
-        $text .= "<table style='width:100%;text-align:center;'>"
-                . "<thead><tr><td style='text-align:left'>Subject</td><td>Score</td><td>No.Questions</td></tr></thead>"
-                . "<tbody>";
-        while ($row = $sql->fetch()) {
-            if(is_numeric($row['count'])) {
-                $text .= '<tr><td style=\'text-align:left\'>' . $row['subject'] . '</td><td>' . $row['percent'] . '%</td><td>' . $row['count'] . '</td></tr>';
-            }
+    $today = $sql->fetch();
+    $answeredToday = is_array($today) ? (int) $today['cntr'] : 0;
+
+    if ($answeredToday > 0) {
+        $percent = htmlspecialchars($today['percent']);
+
+        if ($answeredToday === 1) {
+            $text .= '<p>1 question has been answered<br/>today with ' . $percent . '% accuracy.</p><br/>';
+        } else {
+            $text .= '<p>' . $answeredToday . ' questions have been answered<br/>today with ' . $percent . '% accuracy.</p><br/>';
         }
-        $sql->gen("SELECT COUNT(id) as cntr, CAST((SUM(correct)/COUNT(id)*100) as DECIMAL(5,2)) as percent
-            FROM e107.`e107_tkReport` WHERE username = '$userName'
-            AND answereddatestamp >= unix_timestamp(CURRENT_DATE())
-            AND answereddatestamp <= unix_timestamp(CURRENT_DATE() + INTERVAL 1 DAY)");
-        $text .= '</tbody></table>';
-        while ($row = $sql->fetch()) {
-            if((int)$row['cntr']==1) {
-                $text .= '<p>' . $row['cntr'] . ' question has been answered<br/>today with ' . $row['percent'] . '% accuracy.<p>';
-            }elseif(!(int)$row['cntr']==0) {
-                $text .= '<p>' . $row['cntr'] . ' questions have been answered<br/>today with ' . $row['percent'] . '% accuracy.<p><br/>';
-                if($settingsHolder['minQuestionCount']<=$row['cntr']) {
-                    $text .= '<label style="background: Darkgreen;">Games enabled</label><br/>';
-                    $currentScore = e107::getDb()->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $this->user . '"');
-                    $text .= '<label>You have '.$currentScore['timekeeper_minutes'].' minutes remaining.</label>';
-                }else{
-                    $text .= '<label>Games disabled</label>';
-                }
-            }
+
+        if ($minQuestions <= $answeredToday) {
+            $text .= '<label style="background: Darkgreen;">Games enabled</label><br/>';
+            $currentScore = $sql->retrieve('timekeeperScore', 'timekeeper_lifetimecorrect, timekeeper_lifetimequestions, timekeeper_minutes', 'timekeeper_username="' . $u . '"');
+            $minutes = is_array($currentScore) ? (int) $currentScore['timekeeper_minutes'] : 0;
+            $text .= '<label>You have ' . $minutes . ' minutes remaining.</label>';
+        } else {
+            $text .= '<label>Games disabled</label>';
         }
-        
-        e107::getRender()->tablerender('Score', $text);
-}
+    }
+
+    e107::getRender()->tablerender('Score', $text);
+}

+ 10 - 0
timekeeper_sql.php

@@ -66,6 +66,16 @@ CREATE TABLE `timekeeperStats` (
   PRIMARY KEY (`timekeeper_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
+CREATE TABLE `timekeeperStatistics` (
+  `uniqueId` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `userName` varchar(255) NOT NULL DEFAULT '',
+  `gameName` varchar(255) NOT NULL DEFAULT '',
+  `playDate` int(10) unsigned NOT NULL DEFAULT '0',
+  `minutesPlayed` int(10) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`uniqueId`),
+  KEY `user_game_date` (`userName`,`gameName`,`playDate`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
 CREATE TABLE `tkLastQuestion` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
   `user` varchar(100) NOT NULL DEFAULT '',