0.0, 'hard' => 1.3, 'medium' => 3.0, 'easy' => 5.0); // Stored qualkard_avg value for an unread card. A literal, non-empty marker: it survives PHP's // empty() -- which drops the numeric string "0" on save -- and stays distinct from a real average // of 0 (a card graded hairy from new). Graded averages are stored with decimals for the same reason. const UNREAD = 'new'; /** * Running average. New card (unread, $old === null) seeds at N - 23%; otherwise the qualcard mean. */ public static function nextAverage($old, $score) { return $old === null ? ($score - 0.23 * $score) : (($old + $score) / 2.0); } /** * Average -> review interval in days (qualcard grades G..A). */ public static function intervalDays($avg) { if ($avg <= 0.60) { return 1; // G } if ($avg <= 1.35) { return 2; // F } if ($avg <= 2.20) { return 3; // E } if ($avg <= 3.05) { return 5; // D } if ($avg <= 3.90) { return 7; // C } if ($avg <= 4.92) { return 9; // B } return 11; // A } /** * Next due timestamp: midnight, intervalDays() out. */ public static function dueFrom($avg, $now = null) { $now = $now ?: time(); return strtotime('+'.self::intervalDays($avg).' days', strtotime('today', $now)); } /** * Mastery badge for a card's average: label + CSS class. null (unread) = "new" (yellow); otherwise * a percent of 5, coloured red (<50) / green (<90) / blue (>=90). * * @param float|null $avg * @return array{label:string,class:string} */ public static function badge($avg) { if ($avg === null) { return array('label' => t('new'), 'class' => 'qk-new'); } $pct = (int) round($avg / 5.0 * 100); $class = $pct < 50 ? 'qk-low' : ($pct < 90 ? 'qk-mid' : 'qk-high'); return array('label' => $pct.'%', 'class' => $class); } /** * A stored qualkard_avg -> float, or null when the card is unread. The unread marker is the * literal self::UNREAD; '' and null are also treated as unread for any card written before it. * * @param string|null $raw * @return float|null */ private static function parseAvg($raw) { return ($raw === '' || $raw === null || $raw === self::UNREAD) ? null : (float) $raw; } /** * Is QualKard enabled for this board? * * @param int $project_id * @return bool */ public function isEnabled($project_id) { return (int) $this->projectMetadataModel->get($project_id, 'qualkard_enabled', 0) === 1; } /** * Is the RecoReco plugin installed? (QualKard needs its move engine.) * * @return bool */ public function hasRecoReco() { foreach ($this->container['pluginLoader']->getPlugins() as $plugin) { if ($plugin->getPluginName() === 'RecoReco') { return true; } } return false; } /** * The column id stored for a board role ('draft', 'study', 'hairy', ... , 'done'). * * @param int $project_id * @param string $role * @return int */ public function col($project_id, $role) { return (int) $this->projectMetadataModel->get($project_id, 'qualkard_col_'.$role, 0); } /** * Is FinanceBuddy enabled on this board? QualKard refuses to convert a FinanceBuddy board (its * restructure would wreck the finance columns). * * @param int $project_id * @return bool */ public function fbEnabled($project_id) { return (int) $this->projectMetadataModel->get($project_id, 'financebuddy_enabled', 0) === 1; } /** * Write the inert RecoReco config for a card: move-mode to Study, fire from anywhere but Done, * daily (innocuous -- QualKard sets the due date on each grade), anchor 0 so RecoReco skips it * until the first grade arms it. QualKard never toggles recoreco_enabled again. * * @param int $task_id * @param int $project_id */ public function arm($task_id, $project_id) { $this->taskMetadataModel->save($task_id, array( 'recoreco_enabled' => 1, 'recoreco_move' => 1, 'recoreco_target_column' => $this->col($project_id, 'study'), 'recoreco_trigger_column' => $this->col($project_id, 'done'), 'recoreco_trigger_invert' => 1, 'recoreco_frequency' => 'daily', 'recoreco_anchor' => 0, )); // QualKard adopts the card as a fresh flashcard, so wipe any stale RecoReco copy lineage. A // card that was a RecoReco duplicate carries recoreco_clone/recoreco_source; left in place it // makes an armed QualKard card look like a copy (yellow icon, "cannot be made recurring" // modal) while the cron still moves it. Removing them makes it a clean move-mode template. $this->taskMetadataModel->remove($task_id, 'recoreco_clone'); $this->taskMetadataModel->remove($task_id, 'recoreco_source'); } /** * Reset one card: move to Drafts, mark unread, arm inert, clear the due date. * * @param int $task_id * @param int $project_id */ public function resetCard($task_id, $project_id) { $task = $this->taskFinderModel->getById($task_id); if (empty($task)) { return; } $this->taskPositionModel->movePosition($project_id, $task_id, $this->col($project_id, 'draft'), 1, (int) $task['swimlane_id'], false); $this->taskMetadataModel->save($task_id, array('qualkard_avg' => self::UNREAD)); // brand-new card $this->arm($task_id, $project_id); $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => 0)); } /** * Reset every card on a board. * * @param int $project_id */ public function resetAll($project_id) { foreach ($this->taskFinderModel->getAll($project_id) as $task) { $this->resetCard($task['id'], $project_id); } } /** * Grade a card by a score (a grade column's value): update the running average, compute the next * due date, and arm the anchor. RecoReco moves the card from here. * * @param int $task_id * @param float $score */ public function grade($task_id, $score) { $raw = $this->taskMetadataModel->get($task_id, 'qualkard_avg', self::UNREAD); $old = self::parseAvg($raw); $avg = self::nextAverage($old, $score); $due = self::dueFrom($avg); // Store with decimals so an exact 0 is "0.0000", never the empty()-dropped "0". $this->taskMetadataModel->save($task_id, array( 'qualkard_avg' => sprintf('%.4f', $avg), 'recoreco_anchor' => $due, )); $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due)); } /** * The mastery badge (label + CSS class) for a card, from its stored average. * * @param int $task_id * @return array */ public function cardBadge($task_id) { $raw = $this->taskMetadataModel->get($task_id, 'qualkard_avg', self::UNREAD); return self::badge(self::parseAvg($raw)); } }