13 Commits
v0.1 ... master

Author SHA1 Message Date
3a67332149 drop-to-Drafts un-studies one card (new badge, clear due, remove anchor -> inert) (v1.1.4) 2026-07-13 09:35:03 -03:00
ccfaeed2ad clear stale recoreco_clone/source on arm so ex-copies become clean templates (v1.1.3) 2026-07-13 09:20:07 -03:00
a55fc67a47 README ASCII 2026-07-13 08:56:20 -03:00
0ab9d545bc README: More-plugins section -> Dr. Beco heading + full sibling list (v1.1.1) 2026-07-13 08:48:03 -03:00
3695010636 v1.1 bugfix php "0" is true trap 2026-07-11 16:24:11 -03:00
88a271e5b8 v1.0 -- README rewrite (study workflow, board, badge, deps) + release bump (v1.0.0) 2026-07-11 16:06:17 -03:00
374517496a Integrations hardening -- Set up dimmed off RecoReco/FinanceBuddy; honest Disable (v0.6.0) 2026-07-11 15:58:15 -03:00
2fff0af063 mastery badge + fix Integrations helper access (restores Reset button) (v0.5.0) t1 2026-07-11 15:49:23 -03:00
e1e4c36180 mastery badge + fix Integrations helper access (restores Reset button) (v0.5.0) 2026-07-11 15:46:23 -03:00
8648b67061 grade engine -- reset new cards + grade on drag to a grade column (v0.4.0) 2026-07-11 15:41:47 -03:00
9247057179 per-board study setup (restructure columns, reset, arm RecoReco) via Integrations (v0.3.0) 2026-07-11 15:34:39 -03:00
e7e2b930f3 QualKard: scaffold plugin from Skeleton + ported qualcard grading math (v0.2.0) t1 2026-07-11 15:30:55 -03:00
a2f00eb3fe QualKard: scaffold plugin from Skeleton + ported qualcard grading math (v0.2.0) 2026-07-11 15:30:37 -03:00
11 changed files with 606 additions and 64 deletions

18
Asset/css/qualkard.css Normal file
View File

@@ -0,0 +1,18 @@
/* QualKard mastery badge (under the card title on the board). */
.qk-line {
margin-top: 2px;
}
.qk-badge {
display: inline-block;
padding: 0 6px;
border-radius: 3px;
color: #fff;
font-size: 0.85em;
font-weight: bold;
}
.qk-new { background: #e6b800; } /* unread */
.qk-low { background: #c0392b; } /* < 50% */
.qk-mid { background: #27ae60; } /* 50-89% */
.qk-high { background: #2980b9; } /* >= 90% */

View File

@@ -1 +0,0 @@
/* Skeleton plugin styles -- add CSS here. Loaded via template:layout:css. */

View File

@@ -1 +0,0 @@
// Skeleton plugin script -- add JS here. Loaded via template:layout:js.

View File

@@ -0,0 +1,99 @@
<?php
namespace Kanboard\Plugin\QualKard\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Core\Controller\AccessForbiddenException;
use Kanboard\Plugin\QualKard\Helper\QualKardHelper;
/**
* QualKard board actions, posted from the project Integrations page: set up the study board, reset
* all cards, or disable. Project-manager gated (the restructure is destructive).
*/
class QualKardController extends BaseController
{
/**
* Turn this project into a study board: rename the first 7 columns to the standard names (create
* or delete to reach exactly 7), move every card to Drafts, store the column roles, reset all
* cards to unread + armed-inert, and enable. Once-per-conversion, explicit + confirmed.
*/
public function setup()
{
$project = $this->authorize();
$helper = new QualKardHelper($this->container);
$names = array('Drafts', 'Study', 'Hairy', 'Hard', 'Medium', 'Easy', 'Done');
$columns = $this->columnModel->getAll($project['id']); // position-ordered rows
$ids = array();
for ($i = 0; $i < 7; $i++) {
if (isset($columns[$i])) {
$this->columnModel->update($columns[$i]['id'], $names[$i]);
$ids[$i] = (int) $columns[$i]['id'];
} else {
$ids[$i] = (int) $this->columnModel->create($project['id'], $names[$i]);
}
}
// Move every card to Drafts BEFORE deleting extras -- remove() is a raw DELETE (tasks cascade).
foreach ($this->taskFinderModel->getAll($project['id']) as $task) {
$this->taskPositionModel->movePosition($project['id'], $task['id'], $ids[0], 1, $task['swimlane_id'], false);
}
for ($i = 7, $n = count($columns); $i < $n; $i++) {
$this->columnModel->remove($columns[$i]['id']);
}
$roles = array('draft', 'study', 'hairy', 'hard', 'medium', 'easy', 'done');
foreach ($roles as $k => $role) {
$this->projectMetadataModel->save($project['id'], array('qualkard_col_'.$role => $ids[$k]));
}
$helper->resetAll($project['id']);
$this->projectMetadataModel->save($project['id'], array('qualkard_enabled' => 1));
$this->flash->success(t('QualKard study board is ready.'));
$this->redirectToIntegrations($project);
}
/**
* Reset every card on the board: unread, moved to Drafts, armed-inert. Keeps the columns.
*/
public function reset()
{
$project = $this->authorize();
(new QualKardHelper($this->container))->resetAll($project['id']);
$this->flash->success(t('All cards reset.'));
$this->redirectToIntegrations($project);
}
/**
* Disable QualKard on this board (non-destructive: leaves columns and cards as they are).
*/
public function disable()
{
$project = $this->authorize();
$this->projectMetadataModel->save($project['id'], array('qualkard_enabled' => 0));
$this->flash->success(t('QualKard disabled on this board.'));
$this->redirectToIntegrations($project);
}
// Project-manager gate + CSRF, shared by every action.
private function authorize()
{
$project = $this->getProject();
if (! $this->helper->user->hasProjectAccess('ProjectEditController', 'edit', $project['id'])) {
throw new AccessForbiddenException();
}
$this->checkCSRFForm();
return $project;
}
private function redirectToIntegrations(array $project)
{
$this->response->redirect($this->helper->url->to('ProjectViewController', 'integrations', array('project_id' => $project['id'])));
}
}

265
Helper/QualKardHelper.php Normal file
View File

@@ -0,0 +1,265 @@
<?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);
}
}
/**
* 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)
{
$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));
}
}

View File

@@ -1,35 +1,82 @@
<?php
namespace Kanboard\Plugin\Skeleton;
namespace Kanboard\Plugin\QualKard;
use Kanboard\Core\Plugin\Base;
use Kanboard\Model\TaskModel;
/**
* QualKard -- flashcard spaced repetition on Kanboard cards.
*
* Each card is a flashcard (title = prompt, description = answer). You grade a due card by dragging
* it into a grade column (Hairy/Hard/Medium/Easy); QualKard turns the grade into a due date via the
* qualcard average + interval, and the companion RecoReco plugin does all the card movement.
*
* Requires RecoReco (the move engine). Grading math is ported from Ruben's C program "qualcard".
*/
class Plugin extends Base
{
public function initialize()
{
// 1. Render a visible word at the top of every page (the demo output).
$this->template->hook->attach('template:layout:top', 'skeleton:layout/header');
$container = $this->container;
// 2. Load the plugin stylesheet (currently empty -- proves the CSS hook fires).
// Per-board setup/reset/disable on Settings -> Integrations.
$this->template->hook->attach('template:project:integrations', 'qualKard:project/integration');
// New card on a QualKard board -> reset it (unread, armed inert, moved to Drafts).
$this->hook->on('model:task:creation:aftersave', function ($task_id) use ($container) {
$helper = new \Kanboard\Plugin\QualKard\Helper\QualKardHelper($container);
$task = $container['taskFinderModel']->getById($task_id);
if (! empty($task) && $helper->isEnabled($task['project_id'])) {
$helper->resetCard($task_id, (int) $task['project_id']);
}
});
// Card dragged INTO a grade column -> grade it (RecoReco then handles the movement). The
// plugin on() wrapper drops the event, so we subscribe on the dispatcher to get the TaskEvent.
$this->dispatcher->addListener(TaskModel::EVENT_MOVE_COLUMN, function ($event) use ($container) {
$task = $event['task'];
$helper = new \Kanboard\Plugin\QualKard\Helper\QualKardHelper($container);
if (! $helper->isEnabled($task['project_id'])) {
return;
}
$dst = (int) $event['dst_column_id'];
foreach (array('hairy', 'hard', 'medium', 'easy') as $role) {
if ($dst === $helper->col($task['project_id'], $role)) {
$helper->grade($task['id'], \Kanboard\Plugin\QualKard\Helper\QualKardHelper::SCORE[$role]);
return;
}
}
// Dropped back into Drafts -> surgically un-study this one card (new badge, no due, inert).
if ($dst === $helper->col($task['project_id'], 'draft')) {
$helper->park($task['id']);
}
});
// Mastery badge under the card title on the board, plus its stylesheet.
$this->template->hook->attach('template:board:private:task:after-title', 'qualKard:board/badge');
$this->template->hook->attach('template:board:public:task:after-title', 'qualKard:board/badge');
$this->hook->on('template:layout:css', array(
'template' => 'plugins/Skeleton/Asset/css/skeleton.css',
'template' => 'plugins/QualKard/Asset/css/qualkard.css',
));
}
// 3. Load the plugin script (currently empty -- proves the JS hook fires).
$this->hook->on('template:layout:js', array(
'template' => 'plugins/Skeleton/Asset/js/skeleton.js',
));
public function getHelpers()
{
return array(
'Plugin\QualKard\Helper' => array('QualKardHelper'),
);
}
public function getPluginName()
{
return 'Skeleton';
return 'QualKard';
}
public function getPluginDescription()
{
return t('Reusable skeleton/template for building Kanboard plugins.');
return t('Flashcard spaced repetition: grade a card by dragging it to a column, and it comes back when due (needs RecoReco).');
}
public function getPluginAuthor()
@@ -39,12 +86,12 @@ class Plugin extends Base
public function getPluginVersion()
{
return '0.1.0';
return '1.1.4';
}
public function getPluginHomepage()
{
return 'https://code.beco.cc/beco/kanboard-plugin-skeleton';
return 'https://code.beco.cc/beco/QualKard';
}
public function getCompatibleVersion()

161
README.md
View File

@@ -1,66 +1,133 @@
# Skeleton -- a Kanboard plugin template
# QualKard -- flashcard spaced repetition on your Kanboard
A minimal, working Kanboard plugin that you copy and rename as the starting point for a
real plugin. By itself it does only one trivial thing: it renders the word "Skeleton" at
the top of every page. It changes no data and runs no database migration.
Turn a Kanboard board into a spaced-repetition flashcard deck. Each card is a flashcard --
title is the prompt, description is the answer. You study a card, grade how it went by
dragging it into a grade column, and QualKard schedules when it should come back. Cards you
find easy drift far into the future; hairy ones return tomorrow.
## What it demonstrates
The scheduling math is ported from Ruben's C program **qualcard** (the "K" here just marks the
Kanboard port). The card movement -- bringing a card back when it falls due -- is done by the
companion **RecoReco** plugin, so QualKard needs RecoReco installed and RecoReco's cron running.
- A complete `Plugin.php` registration class with all the metadata Kanboard shows in
Settings -> Plugins (name, description, author, version, homepage, compatible version).
- A template hook (`template:layout:top`) that injects a template into the page.
- Asset hooks (`template:layout:css` and `template:layout:js`) that load a stylesheet and
a script. They are empty for now but prove the injection path works -- handy when a real
plugin needs custom CSS or JS.
- The standard plugin directory layout, with stub folders (`Controller/`, `Model/`,
`Schema/`, `Locale/`, `Test/`) ready to grow into.
QualKard is **opt-in per board**: installing it changes nothing until a project manager sets a
specific board up under Settings -> Integrations. Setting a board up **restructures its
columns**, so it is a deliberate, confirmed action -- see below.
## The study board
Setting a board up gives it exactly seven columns, each with a fixed role:
```
Drafts | Study | Hairy | Hard | Medium | Easy | Done
```
- **Drafts** -- where new and reset cards wait. A draft is inert: it never comes due on its own.
- **Study** -- the review queue. RecoReco moves a card here when it falls due; this column is
itself the "overdue" backlog, so it can hold more than one day's worth.
- **Hairy / Hard / Medium / Easy** -- the four grade columns. You review a card in Study, then
drag it into one of these to record how it went.
- **Done** -- a resting place you drag a card to when you want it out of rotation. A card in Done
does not come back (it is the one column that does not re-trigger).
## Studying
1. Read the prompt (the card title) in **Study**, recall the answer, then open the card to check
the description.
2. Drag the card into the grade column that matches how it went:
| Column | Meaning | Score |
|--------|---------|------:|
| Hairy | no idea / wrong | 0.0 |
| Hard | right, but a struggle | 1.3 |
| Medium | right, some effort | 3.0 |
| Easy | instant | 5.0 |
3. QualKard folds that score into the card's running average and sets the card's **due date** the
right number of days out (1 day for a hairy average, up to 11 for a mastered one). RecoReco
picks the card up from the grade column and, when it next falls due, moves it back to **Study**.
You never edit a due date or move a card back yourself -- grading is the only action.
### How the schedule is computed
Each card keeps a running average of its scores. A brand-new card is seeded a little below its
first score; after that the average is the mean of the old average and the new score. The average
maps to an interval:
| Average | Interval |
|------------|---------:|
| <= 0.60 | 1 day |
| <= 1.35 | 2 days |
| <= 2.20 | 3 days |
| <= 3.05 | 5 days |
| <= 3.90 | 7 days |
| <= 4.92 | 9 days |
| otherwise | 11 days |
So a card you keep grading Easy climbs toward an 11-day gap, while one you keep missing stays at a
day or two until it sticks.
## The mastery badge
Each card shows a small badge under its title, on the board and at the top of the opened card:
- **new** (yellow) -- an unread card that has never been graded.
- a **percent** of mastery (the average as a fraction of 5), coloured **red** below 50%,
**green** through 89%, and **blue** at 90% and up.
## Requirements
- Kanboard >= 1.2.0
- The **RecoReco** plugin, installed and enabled, with its cron task running (RecoReco is what
moves due cards back to Study). QualKard's setup button stays disabled until RecoReco is present.
QualKard refuses to set up a board that has **FinanceBuddy** enabled: the restructure would wreck a
finance board's columns. Disable FinanceBuddy on that board first, or use a different board.
## Installation
No build step and no dependencies.
Copy this folder into your Kanboard installation as `plugins/QualKard/`. The directory name must be
exactly `QualKard` (Kanboard derives the plugin namespace from the folder name). No build step and
no database migration.
1. Copy this folder into your Kanboard installation as `plugins/Skeleton/`.
2. Reload any page. The word "Skeleton" appears at the top.
3. Confirm it under Settings -> Plugins.
Then, on the board you want to study:
To uninstall, delete `plugins/Skeleton/`. Nothing else is left behind.
1. Go to Settings -> Integrations.
2. Under QualKard, click **Set up QualKard study board**, and confirm. This renames the board's
columns to the seven roles above (creating or removing columns to reach exactly seven) and moves
every existing card into Drafts.
3. Make sure RecoReco's cron is running so due cards come back.
## Directory layout
From the same page you can later **Reset all cards** (send every card back to Drafts, unread) or
**Disable QualKard machinery** (stop the automation without touching your columns or cards).
```
Skeleton/
Plugin.php Registration and hook wiring (the only required file).
README.md
LICENSE AGPL-3.0.
Template/
layout/header.php The visible "Skeleton" word.
Asset/
css/skeleton.css Loaded via template:layout:css.
js/skeleton.js Loaded via template:layout:js.
Controller/ Stub for future request handlers.
Model/ Stub for future business logic / DB access.
Schema/ Stub for future database migrations.
Locale/ Stub for future translations (e.g. pt_BR/, fr_FR/).
Test/ Stub for future unit tests.
```
## How it stores data
## How to fork this into a new plugin
1. Copy the folder and rename it, e.g. `plugins/MyPlugin/`. The folder name must match the
namespace and start with a capital letter.
2. In `Plugin.php`, change the namespace from `Kanboard\Plugin\Skeleton` to
`Kanboard\Plugin\MyPlugin`.
3. Update the metadata methods (`getPluginName`, `getPluginDescription`, `getPluginAuthor`,
`getPluginVersion`, `getPluginHomepage`, `getCompatibleVersion`).
4. Update the hook target paths: the lowercase prefix in `'skeleton:layout/header'` and the
`plugins/Skeleton/Asset/...` asset paths must match the new plugin name.
5. Replace `Template/layout/header.php` with your real template, or attach to a different
hook. See the Kanboard plugin hooks documentation for the full list of hook points.
No database migration. The per-board enable flag and the seven column roles are stored in project
metadata; each card's running average lives in task metadata, and QualKard writes the card's due
date and RecoReco's per-card recurrence config. Uninstalling the plugin leaves that metadata
harmlessly in place -- your columns and cards are untouched.
## License
AGPL-3.0. See LICENSE.
## More Kanboard plugins by Dr. Beco
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
- **[RecoReco](https://code.beco.cc/beco/RecoReco)** -- calendar-scheduled recurring cards (yearly,
monthly, weekly, daily), driven by the card due date.
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit
and installments) to cards, shown on the card and totalled per column.
- **[WorkspaceOrg](https://code.beco.cc/beco/WorkspaceOrg)** -- group your projects into personal
per-user workspaces, shown on a "My workspaces" dashboard page with a badge on each project.
- **[OrganonTweaks](https://code.beco.cc/beco/OrganonTweaks)** -- an umbrella of small
quality-of-life board tweaks: remove an empty column, always show the comment icon, emphasize due
dates, extra search keywords and shared board filters, and more.
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
column to another in a single action.
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
board columns so the horizontal scrollbar stays within reach.
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
drag-to-scroll, a wider column gap, and smoother card dragging.

12
Template/board/badge.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
// Mastery badge under the card title on a QualKard board. Server-rendered (survives board refresh).
// Yellow "new" for an unread card; otherwise a percent of 5, red (<50) / green (<90) / blue (>=90).
if (empty($task['project_id']) || ! $this->QualKardHelper->isEnabled($task['project_id'])) {
return;
}
$b = $this->QualKardHelper->cardBadge($task['id']);
?>
<div class="qk-line">
<span class="qk-badge <?= $b['class'] ?>"><?= $this->text->e($b['label']) ?></span>
</div>

View File

@@ -1 +0,0 @@
<div class="skeleton-plugin-marker">Skeleton</div>

View File

@@ -0,0 +1,37 @@
<?php
// QualKard is inside the shared Integrations form, so the buttons use formaction (NOT nested <form>s)
// to post to QualKard's own controller. Set up is destructive; disabling only stops the automation.
$has = $this->QualKardHelper->hasRecoReco();
$fb = $this->QualKardHelper->fbEnabled($project['id']);
$enabled = $this->QualKardHelper->isEnabled($project['id']);
$blocked = ! $has || $fb;
?>
<h3><i class="fa fa-graduation-cap fa-fw" aria-hidden="true"></i> <?= t('QualKard') ?></h3>
<div class="listing">
<?php if ($enabled): ?>
<p class="form-help"><?= t('QualKard is enabled on this board.') ?></p>
<div class="form-actions">
<button type="submit" class="btn" formaction="<?= $this->url->href('QualKardController', 'reset', array('plugin' => 'QualKard', 'project_id' => $project['id'])) ?>" onclick="return confirm('<?= t('Reset every card to unread and move them to Drafts. Continue?') ?>');"><?= t('Reset all cards') ?></button>
<button type="submit" class="btn btn-red" formaction="<?= $this->url->href('QualKardController', 'disable', array('plugin' => 'QualKard', 'project_id' => $project['id'])) ?>"><?= t('Disable QualKard machinery') ?></button>
</div>
<p class="form-help"><?= t('"Disable" only stops the QualKard automation -- it does not restructure the board or touch your cards.') ?></p>
<?php else: ?>
<p class="form-help">
<?php if (! $has): ?>
<?= t('QualKard needs the RecoReco plugin (its move engine).') ?>
<?php elseif ($fb): ?>
<?= t('Disabled while FinanceBuddy is enabled here -- QualKard would restructure the columns.') ?>
<?php else: ?>
<?= t('Set up turns this project into a study board: columns become Drafts | Study | Hairy | Hard | Medium | Easy | Done and every card moves to Drafts.') ?>
<?php endif ?>
</p>
<div class="form-actions"<?= $blocked ? ' style="opacity:0.5"' : '' ?>>
<button type="submit" class="btn btn-blue" <?= $blocked ? 'disabled="disabled"' : '' ?> formaction="<?= $this->url->href('QualKardController', 'setup', array('plugin' => 'QualKard', 'project_id' => $project['id'])) ?>" onclick="return confirm('<?= t('This restructures the board and moves all cards to Drafts. Continue?') ?>');"><?= t('Set up QualKard study board') ?></button>
</div>
<?php endif ?>
</div>
<hr>

View File

@@ -1 +1 @@
QualKard v0.1
QualKard v1.1.4