feat: two-step issue resolution (resolve then delete)
- New PATCH /issues/{id} endpoint to toggle resolved flag
- Add resolved:false to new issues
- Frontend: click "✓ Resolve" marks issue green with strikethrough
- Resolved issues show "↺ Reopen" and "🗑 Delete" buttons
- Delete requires confirmation
- Sort: unresolved first, then resolved
- Issues persist until explicitly deleted
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f2b43fce0b
commit
604d4376b4
3 changed files with 120 additions and 16 deletions
20
main.py
20
main.py
|
|
@ -1368,6 +1368,7 @@ async def add_issue(issue: dict):
|
||||||
"category": issue.get("category", "other"),
|
"category": issue.get("category", "other"),
|
||||||
"author": issue.get("author", "Anonymous").strip(),
|
"author": issue.get("author", "Anonymous").strip(),
|
||||||
"created": datetime.utcnow().isoformat(),
|
"created": datetime.utcnow().isoformat(),
|
||||||
|
"resolved": False,
|
||||||
}
|
}
|
||||||
if not new_issue["title"]:
|
if not new_issue["title"]:
|
||||||
raise HTTPException(status_code=400, detail="Title is required")
|
raise HTTPException(status_code=400, detail="Title is required")
|
||||||
|
|
@ -1376,9 +1377,26 @@ async def add_issue(issue: dict):
|
||||||
return new_issue
|
return new_issue
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/issues/{issue_id}")
|
||||||
|
async def update_issue(issue_id: str, update: dict):
|
||||||
|
"""Update an issue (e.g. toggle resolved state)."""
|
||||||
|
issues = _load_issues()
|
||||||
|
found = None
|
||||||
|
for i in issues:
|
||||||
|
if i.get("id") == issue_id:
|
||||||
|
if "resolved" in update:
|
||||||
|
i["resolved"] = bool(update["resolved"])
|
||||||
|
found = i
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
_save_issues(issues)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/issues/{issue_id}")
|
@app.delete("/issues/{issue_id}")
|
||||||
async def delete_issue(issue_id: str):
|
async def delete_issue(issue_id: str):
|
||||||
"""Resolve (delete) an issue."""
|
"""Permanently delete an issue."""
|
||||||
issues = _load_issues()
|
issues = _load_issues()
|
||||||
issues = [i for i in issues if i.get("id") != issue_id]
|
issues = [i for i in issues if i.get("id") != issue_id]
|
||||||
_save_issues(issues)
|
_save_issues(issues)
|
||||||
|
|
|
||||||
|
|
@ -4209,30 +4209,68 @@ async function refreshIssuesList(win) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort: unresolved first, then resolved
|
||||||
|
issues.sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0));
|
||||||
|
|
||||||
listDiv.innerHTML = '';
|
listDiv.innerHTML = '';
|
||||||
issues.forEach(issue => {
|
issues.forEach(issue => {
|
||||||
const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
|
const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
|
||||||
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
|
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
|
||||||
|
const isResolved = !!issue.resolved;
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'issue-row';
|
row.className = 'issue-row' + (isResolved ? ' issue-resolved' : '');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="display:flex;align-items:center;gap:6px;">
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
<span class="issue-category" style="background:${cat.color}">${cat.label}</span>
|
<span class="issue-category" style="background:${cat.color}">${cat.label}</span>
|
||||||
<strong style="font-size:0.8rem;color:#ddd;">${issue.title}</strong>
|
<strong style="font-size:0.8rem;">${issue.title}</strong>
|
||||||
<span style="margin-left:auto;color:#666;font-size:0.65rem;">${date}</span>
|
<span style="margin-left:auto;color:#666;font-size:0.65rem;">${date}</span>
|
||||||
</div>
|
</div>
|
||||||
${issue.description ? `<div style="color:#aaa;font-size:0.75rem;margin-top:2px;padding-left:4px;">${issue.description}</div>` : ''}
|
${issue.description ? `<div class="issue-description">${issue.description}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const resolveBtn = document.createElement('button');
|
const headerDiv = row.querySelector('div');
|
||||||
resolveBtn.textContent = '✓ Resolve';
|
|
||||||
resolveBtn.className = 'issue-resolve-btn';
|
if (isResolved) {
|
||||||
resolveBtn.addEventListener('click', async () => {
|
// Reopen button
|
||||||
await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
|
const reopenBtn = document.createElement('button');
|
||||||
refreshIssuesList(win);
|
reopenBtn.textContent = '↺ Reopen';
|
||||||
});
|
reopenBtn.className = 'issue-reopen-btn';
|
||||||
row.querySelector('div').appendChild(resolveBtn);
|
reopenBtn.addEventListener('click', async () => {
|
||||||
|
await fetch(`/issues/${issue.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ resolved: false })
|
||||||
|
});
|
||||||
|
refreshIssuesList(win);
|
||||||
|
});
|
||||||
|
headerDiv.appendChild(reopenBtn);
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.textContent = '🗑 Delete';
|
||||||
|
deleteBtn.className = 'issue-delete-btn';
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete issue "${issue.title}"?`)) return;
|
||||||
|
await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
|
||||||
|
refreshIssuesList(win);
|
||||||
|
});
|
||||||
|
headerDiv.appendChild(deleteBtn);
|
||||||
|
} else {
|
||||||
|
// Resolve button (marks as resolved, doesn't delete)
|
||||||
|
const resolveBtn = document.createElement('button');
|
||||||
|
resolveBtn.textContent = '✓ Resolve';
|
||||||
|
resolveBtn.className = 'issue-resolve-btn';
|
||||||
|
resolveBtn.addEventListener('click', async () => {
|
||||||
|
await fetch(`/issues/${issue.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ resolved: true })
|
||||||
|
});
|
||||||
|
refreshIssuesList(win);
|
||||||
|
});
|
||||||
|
headerDiv.appendChild(resolveBtn);
|
||||||
|
}
|
||||||
|
|
||||||
listDiv.appendChild(row);
|
listDiv.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2664,12 +2664,35 @@ table.ts-allegiance td:first-child {
|
||||||
.issue-row {
|
.issue-row {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-row:hover {
|
.issue-row:hover {
|
||||||
background: #1a1a2a;
|
background: #1a1a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-row .issue-description {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-row.issue-resolved {
|
||||||
|
background: rgba(74, 170, 74, 0.12);
|
||||||
|
border-left-color: #4a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-row.issue-resolved strong {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-row.issue-resolved .issue-description {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.issue-category {
|
.issue-category {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|
@ -2680,15 +2703,20 @@ table.ts-allegiance td:first-child {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-resolve-btn {
|
.issue-resolve-btn,
|
||||||
|
.issue-reopen-btn,
|
||||||
|
.issue-delete-btn {
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #4a4;
|
|
||||||
border: 1px solid #4a4;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-left: 8px;
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-resolve-btn {
|
||||||
|
color: #4a4;
|
||||||
|
border: 1px solid #4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-resolve-btn:hover {
|
.issue-resolve-btn:hover {
|
||||||
|
|
@ -2696,6 +2724,26 @@ table.ts-allegiance td:first-child {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-reopen-btn {
|
||||||
|
color: #88a;
|
||||||
|
border: 1px solid #88a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-reopen-btn:hover {
|
||||||
|
background: #88a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-delete-btn {
|
||||||
|
color: #c44;
|
||||||
|
border: 1px solid #c44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-delete-btn:hover {
|
||||||
|
background: #c44;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.issues-form {
|
.issues-form {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-top: 1px solid #444;
|
border-top: 1px solid #444;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue