247 lines
8.0 KiB
PHP
247 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace Kanboard\Plugin\QualKard\Helper;
|
|
|
|
use Kanboard\Core\Base;
|
|
use Kanboard\Model\TaskModel;
|
|
|
|
/**
|
|
* QualKard helper: the qualcard grading math (ported from Ruben's C program) plus the per-board gates.
|
|
*
|
|
* Scores per grade column: hairy=0.0, hard=1.3, medium=3.0, easy=5.0. The running average is
|
|
* M1 = (M0 + N)/2, with a new (unread) card seeded at N - 23%. The average maps to a review interval
|
|
* (qualcard grades G..A, 1..11 days). qualcard's H/same-day and the overdue decay are intentionally
|
|
* NOT ported: there is no "again now", and the Study column is itself the overdue backlog.
|
|
*/
|
|
class QualKardHelper extends Base
|
|
{
|
|
const SCORE = array('hairy' => 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));
|
|
}
|
|
}
|