0.0, 'hard' => 1.3, 'medium' => 3.0, 'easy' => 5.0); /** * 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); } /** * 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); } /** * 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, )); } /** * 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' => '')); // '' = unread $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', ''); $old = ($raw === '' || $raw === null) ? null : (float) $raw; $avg = self::nextAverage($old, $score); $due = self::dueFrom($avg); $this->taskMetadataModel->save($task_id, array( 'qualkard_avg' => (string) $avg, 'recoreco_anchor' => $due, )); $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due)); } }