Add toggle (check/uncheck) to checklist items + "Open in rTasks" button

- checkAC → toggleAC: clicking a checked item unchecks it
- Tokens generated for all items (checked and unchecked)
- Checked items now clickable with green checkmark links
- Uncheck shows amber banner; check shows green banner
- "Open in rTasks" button links to kanban board

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 13:31:55 -07:00
parent 0728c9e516
commit cf296a3bb3
3 changed files with 37 additions and 22 deletions

View File

@ -7,7 +7,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { checklistConfig } from "./lib/checklist-config"; import { checklistConfig } from "./lib/checklist-config";
import { createToken, verifyToken } from "./lib/token"; import { createToken, verifyToken } from "./lib/token";
import { readTask, checkAC } from "./lib/backlog"; import { readTask, toggleAC } from "./lib/backlog";
import { renderEmailHTML, renderWebPage, renderError } from "./lib/render"; import { renderEmailHTML, renderWebPage, renderError } from "./lib/render";
export const checklistCheckRoutes = new Hono(); export const checklistCheckRoutes = new Hono();
@ -30,17 +30,14 @@ checklistCheckRoutes.get("/:token", async (c) => {
} }
try { try {
await checkAC(verified.repo, verified.taskId, verified.acIndex); const result = await toggleAC(verified.repo, verified.taskId, verified.acIndex);
const task = await readTask(verified.repo, verified.taskId);
const tokens = new Map<number, string>(); const tokens = new Map<number, string>();
for (const ac of task.criteria) { for (const ac of result.criteria) {
if (!ac.checked) { tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
}
} }
return c.html(renderWebPage(task.title, verified.taskId, task.criteria, tokens, verified.acIndex)); return c.html(renderWebPage(result.title, verified.taskId, result.criteria, tokens, verified.acIndex, result.toggled));
} catch (err) { } catch (err) {
console.error("[Tasks/checklist] Check error:", err); console.error("[Tasks/checklist] Check error:", err);
return c.html(renderError("Error", "Could not update task. Please try again."), 500); return c.html(renderError("Error", "Could not update task. Please try again."), 500);

View File

@ -87,16 +87,23 @@ export async function readTask(repo: string, taskId: string): Promise<TaskInfo>
} }
/** /**
* Toggle an AC item to checked. Idempotent: if already checked, no-op. * Toggle an AC item: unchecked checked, checked unchecked.
* Returns the new checked state.
*/ */
export async function checkAC(repo: string, taskId: string, acIndex: number): Promise<TaskInfo> { export async function toggleAC(repo: string, taskId: string, acIndex: number): Promise<TaskInfo & { toggled: boolean }> {
const filePath = await findTaskFile(repo, taskId); const filePath = await findTaskFile(repo, taskId);
let content = await Bun.file(filePath).text(); let content = await Bun.file(filePath).text();
const unchecked = `- [ ] #${acIndex} `; const unchecked = `- [ ] #${acIndex} `;
const checked = `- [x] #${acIndex} `; const checked = `- [x] #${acIndex} `;
let nowChecked = false;
if (content.includes(unchecked)) { if (content.includes(unchecked)) {
content = content.replace(unchecked, checked); content = content.replace(unchecked, checked);
nowChecked = true;
await Bun.write(filePath, content);
} else if (content.includes(checked)) {
content = content.replace(checked, unchecked);
nowChecked = false;
await Bun.write(filePath, content); await Bun.write(filePath, content);
} }
@ -105,5 +112,6 @@ export async function checkAC(repo: string, taskId: string, acIndex: number): Pr
status: parseStatus(content), status: parseStatus(content),
criteria: parseAC(content), criteria: parseAC(content),
filePath, filePath,
toggled: nowChecked,
}; };
} }

View File

@ -36,15 +36,15 @@ function progress(criteria: AcceptanceCriterion[]): string {
return `<div class="pb"><div class="pf" style="width:${pct}%"></div></div><div class="pt">${done} of ${total} complete (${pct}%)</div>`; return `<div class="pb"><div class="pf" style="width:${pct}%"></div></div><div class="pt">${done} of ${total} complete (${pct}%)</div>`;
} }
function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>, justChecked?: number): string { function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>, justToggled?: number): string {
return criteria return criteria
.map((ac) => { .map((ac) => {
const hi = ac.index === justChecked ? " hi" : ""; const hi = ac.index === justToggled ? " hi" : "";
if (ac.checked) {
return `<div class="row${hi}"><span class="cb ck">&#10003;</span><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
}
const token = tokens.get(ac.index); const token = tokens.get(ac.index);
const url = token ? `${checklistConfig.baseUrl}/rtasks/check/${token}` : "#"; const url = token ? `${checklistConfig.baseUrl}/rtasks/check/${token}` : "#";
if (ac.checked) {
return `<div class="row${hi}"><a href="${url}" class="cb ck" title="Click to uncheck">&#10003;</a><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
}
return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off">&nbsp;</a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`; return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off">&nbsp;</a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`;
}) })
.join("\n"); .join("\n");
@ -71,23 +71,33 @@ export function renderWebPage(
taskId: string, taskId: string,
criteria: AcceptanceCriterion[], criteria: AcceptanceCriterion[],
tokens: Map<number, string>, tokens: Map<number, string>,
justChecked?: number, justToggled?: number,
wasChecked?: boolean,
): string { ): string {
const justAC = justChecked !== undefined ? criteria.find((c) => c.index === justChecked) : undefined; const justAC = justToggled !== undefined ? criteria.find((c) => c.index === justToggled) : undefined;
const banner = justAC let banner = "";
? `<div style="background:${SUCCESS_COLOR};color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">&#10003; Checked off: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>` if (justAC && wasChecked) {
: ""; banner = `<div style="background:${SUCCESS_COLOR};color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">&#10003; Checked off: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>`;
} else if (justAC && !wasChecked) {
banner = `<div style="background:#f59e0b;color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">&#8635; Unchecked: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>`;
}
const allDone = criteria.every((c) => c.checked); const allDone = criteria.every((c) => c.checked);
const doneMsg = allDone ? `<div style="text-align:center;padding:24px;font-size:18px;color:${SUCCESS_COLOR};">&#127881; All items complete!</div>` : ""; const doneMsg = allDone ? `<div style="text-align:center;padding:24px;font-size:18px;color:${SUCCESS_COLOR};">&#127881; All items complete!</div>` : "";
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} - rTasks</title><style>${styles}</style></head> const rtasksUrl = `${checklistConfig.baseUrl}/demo/rtasks`;
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} - rTasks</title><style>${styles}
.btn{display:inline-block;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;margin-top:16px}
.btn-primary{background:#6366f1;color:#fff}.btn-primary:hover{background:#4f46e5}
</style></head>
<body><div class="card"> <body><div class="card">
${banner} ${banner}
<h1>${esc(title)}</h1> <h1>${esc(title)}</h1>
<div class="sub">${esc(taskId)}</div> <div class="sub">${esc(taskId)}</div>
${progress(criteria)} ${progress(criteria)}
${checklist(criteria, tokens, justChecked)} ${checklist(criteria, tokens, justToggled)}
${doneMsg} ${doneMsg}
<div style="text-align:center;margin-top:20px"><a href="${rtasksUrl}" class="btn btn-primary">Open in rTasks</a></div>
<div class="ft">rTasks &middot; rspace.online</div> <div class="ft">rTasks &middot; rspace.online</div>
</div></body></html>`; </div></body></html>`;
} }