rspace-online/modules/rtasks/lib/render.ts

109 lines
5.5 KiB
TypeScript

/** HTML rendering for email checklist and web confirmation page. */
import type { AcceptanceCriterion } from "./backlog";
import { checklistConfig } from "./checklist-config";
const SUCCESS_COLOR = "#22c55e";
function esc(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
const styles = `
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px}
.card{max-width:600px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px}
h1{font-size:20px;margin:0 0 4px;color:#0f172a}
.sub{font-size:14px;color:#64748b;margin-bottom:24px}
.row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9}
.row:last-child{border-bottom:none}
.cb{width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;text-decoration:none;flex-shrink:0;margin-top:1px}
.uc{border:2px solid #cbd5e1;color:#cbd5e1;background:#fff}
.uc:hover{border-color:#6366f1;color:#6366f1}
.ck{border:2px solid ${SUCCESS_COLOR};background:${SUCCESS_COLOR};color:#fff}
.txt{font-size:15px;line-height:1.5}
.done{color:#94a3b8;text-decoration:line-through}
.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px}
.pb{height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;margin:16px 0 8px}
.pf{height:100%;background:${SUCCESS_COLOR};border-radius:3px}
.pt{font-size:13px;color:#64748b;margin-bottom:24px}
.hi{background:#f0fdf4;border-radius:8px;padding:2px 0}
`;
function progress(criteria: AcceptanceCriterion[]): string {
const total = criteria.length;
const done = criteria.filter((c) => c.checked).length;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
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>, justToggled?: number): string {
return criteria
.map((ac) => {
const hi = ac.index === justToggled ? " hi" : "";
const token = tokens.get(ac.index);
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>`;
})
.join("\n");
}
export function renderEmailHTML(
title: string,
taskId: string,
criteria: AcceptanceCriterion[],
tokens: Map<number, string>,
): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${styles}</style></head>
<body><div class="card">
<h1>${esc(title)}</h1>
<div class="sub">${esc(taskId)} &middot; Click a checkbox to mark it done</div>
${progress(criteria)}
${checklist(criteria, tokens)}
<div class="ft">Sent by rTasks &middot; Links expire in ${checklistConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
export function renderWebPage(
title: string,
taskId: string,
criteria: AcceptanceCriterion[],
tokens: Map<number, string>,
justToggled?: number,
wasChecked?: boolean,
): string {
const justAC = justToggled !== undefined ? criteria.find((c) => c.index === justToggled) : undefined;
let banner = "";
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 doneMsg = allDone ? `<div style="text-align:center;padding:24px;font-size:18px;color:${SUCCESS_COLOR};">&#127881; All items complete!</div>` : "";
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">
${banner}
<h1>${esc(title)}</h1>
<div class="sub">${esc(taskId)}</div>
${progress(criteria)}
${checklist(criteria, tokens, justToggled)}
${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></body></html>`;
}
export function renderError(title: string, message: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error - rTasks</title><style>${styles} .err{color:#ef4444}</style></head>
<body><div class="card" style="text-align:center"><h1 class="err">${esc(title)}</h1><p style="color:#64748b">${esc(message)}</p><div class="ft">rTasks &middot; rspace.online</div></div></body></html>`;
}