Files
QualKard/Helper/QualKardHelper.php

266 lines
9.0 KiB
PHP
Raw Normal View History

<?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);
2026-07-11 16:24:11 -03:00
// 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);
}
2026-07-11 16:24:11 -03:00
/**
* 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);
2026-07-11 16:24:11 -03:00
$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);
}
}
/**
* Surgically un-study ONE card that the user dropped back into Drafts: wipe its mastery to
* unread, clear the due date, and make it inert to RecoReco. Unlike resetCard() this does not
* move or re-arm the card (it is already in Drafts and already armed) -- a lighter, per-card
* counterpart to the board-wide Reset.
*
* The anchor is REMOVED, not saved as 0: Kanboard's metadata save drops the string "0" (empty()),
* which would leave the old positive anchor in place and let RecoReco pull the card back out of
* Drafts on the next cron. A removed key reads back as the default 0, so RecoReco skips it.
*
* @param int $task_id
*/
public function park($task_id)
{
$this->taskMetadataModel->save($task_id, array('qualkard_avg' => self::UNREAD)); // mastery -> new
$this->taskMetadataModel->remove($task_id, 'recoreco_anchor'); // -> inert
$this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => 0));
}
/**
* 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)
{
2026-07-11 16:24:11 -03:00
$raw = $this->taskMetadataModel->get($task_id, 'qualkard_avg', self::UNREAD);
$old = self::parseAvg($raw);
$avg = self::nextAverage($old, $score);
$due = self::dueFrom($avg);
2026-07-11 16:24:11 -03:00
// Store with decimals so an exact 0 is "0.0000", never the empty()-dropped "0".
$this->taskMetadataModel->save($task_id, array(
2026-07-11 16:24:11 -03:00
'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)
{
2026-07-11 16:24:11 -03:00
$raw = $this->taskMetadataModel->get($task_id, 'qualkard_avg', self::UNREAD);
2026-07-11 16:24:11 -03:00
return self::badge(self::parseAvg($raw));
}
}