Merge pull request #3 from ok2/codex/add-feature-to-prioritize--decided--ideas

Implement finalized idea support
This commit is contained in:
Kiyomichi Kosaka 2025-06-08 21:37:44 +02:00 committed by GitHub
commit 5a1f4ca7fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 112 additions and 39 deletions

View File

@ -227,13 +227,18 @@
margin-top: 30px; margin-top: 30px;
} }
.idea-card { .idea-card {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 15px; border-radius: 15px;
padding: 20px; padding: 20px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.idea-card.decided {
background: rgba(0, 255, 0, 0.1);
border-color: rgba(0, 255, 0, 0.4);
}
.idea-card h3 { .idea-card h3 {
margin-bottom: 20px; /* pushes the next formgroup down */ margin-bottom: 20px; /* pushes the next formgroup down */
@ -810,13 +815,15 @@
} }
// Render cards view // Render cards view
function renderCardsView() { function renderCardsView() {
const container = document.getElementById('cardsView'); const container = document.getElementById('cardsView');
container.innerHTML = ''; container.innerHTML = '';
const decidedIdea = currentTour.ideas.find(i => i.decided);
currentTour.ideas.forEach(idea => { currentTour.ideas.forEach(idea => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'idea-card'; card.className = 'idea-card';
if (idea.decided) card.classList.add('decided');
let timeBadge = ""; let timeBadge = "";
if (idea.start_time && idea.end_time) { if (idea.start_time && idea.end_time) {
@ -863,23 +870,30 @@
<h3 class="idea-title">${idea.name}</h3> <h3 class="idea-title">${idea.name}</h3>
${timeBadge} ${timeBadge}
<p class="idea-description" style="margin-top: 8px;">${linkify(idea.description)}</p> <p class="idea-description" style="margin-top: 8px;">${linkify(idea.description)}</p>
<div class="vote-section"> <div class="vote-section">
<span class="vote-count">👍 ${idea.voters.length} votes</span> <span class="vote-count">👍 ${idea.voters.length} votes</span>
<button class="btn btn-secondary btn-small" onclick="voteForIdea('${idea.id}')">Vote</button> ${decidedIdea ? '' : `<button class="btn btn-secondary btn-small" onclick="voteForIdea('${idea.id}')">Vote</button>`}
</div> ${decidedIdea ? '' : `<button class="btn btn-secondary btn-small" onclick="decideIdea('${idea.id}')">Decide</button>`}
</div>
<div class="voters-list"> <div class="voters-list">
${idea.voters.map(voter => `<span class="voter-chip">${voter}</span>`).join('')} ${idea.voters.map(voter => `<span class="voter-chip">${voter}</span>`).join('')}
</div> </div>
`; `;
container.appendChild(card); container.appendChild(card);
}); });
}
document.querySelectorAll('#addIdeaForm input, #addIdeaForm textarea, #addIdeaForm button').forEach(el => {
el.disabled = !!decidedIdea;
});
}
// Render matrix view // Render matrix view
function renderMatrixView() { function renderMatrixView() {
const container = document.getElementById('matrixView'); const container = document.getElementById('matrixView');
const allVoters = [...new Set(currentTour.ideas.flatMap(idea => idea.voters))]; const decidedIdea = currentTour.ideas.find(i => i.decided);
const allVoters = [...new Set(currentTour.ideas.flatMap(idea => idea.voters))];
let html = '<table class="matrix-table"><thead><tr>' let html = '<table class="matrix-table"><thead><tr>'
+ '<th>Idea</th><th>Time Range</th>'; + '<th>Idea</th><th>Time Range</th>';
@ -888,9 +902,9 @@
}); });
html += '<th>Total</th></tr></thead><tbody>'; html += '<th>Total</th></tr></thead><tbody>';
currentTour.ideas.forEach(idea => { currentTour.ideas.forEach(idea => {
// Format time only (HH:mm) in Norwegian: // Format time only (HH:mm) in Norwegian:
let timeCell = ""; let timeCell = "";
if (idea.start_time && idea.end_time) { if (idea.start_time && idea.end_time) {
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', { const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
hour: '2-digit', minute: '2-digit', hour12: false hour: '2-digit', minute: '2-digit', hour12: false
@ -913,18 +927,23 @@
timeCell = `<td style="color: var(--text-secondary);"></td>`; timeCell = `<td style="color: var(--text-secondary);"></td>`;
} }
html += `<tr>` let rowStyle = idea.decided ? ' style="background: rgba(0,255,0,0.1)"' : '';
html += `<tr${rowStyle}>`
+ `<td><strong>${idea.name}</strong><br><small>${linkify(idea.description)}</small></td>` + `<td><strong>${idea.name}</strong><br><small>${linkify(idea.description)}</small></td>`
+ timeCell; + timeCell;
allVoters.forEach(voter => { allVoters.forEach(voter => {
html += `<td class="vote-cell">${idea.voters.includes(voter) ? '✓' : ''}</td>`; html += `<td class="vote-cell">${idea.voters.includes(voter) ? '✓' : ''}</td>`;
}); });
html += `<td class="vote-cell">${idea.voters.length}</td></tr>`; html += `<td class="vote-cell">${idea.voters.length}</td></tr>`;
}); });
html += '</tbody></table>'; html += '</tbody></table>';
container.innerHTML = html; container.innerHTML = html;
}
document.querySelectorAll('#addIdeaForm input, #addIdeaForm textarea, #addIdeaForm button').forEach(el => {
el.disabled = !!decidedIdea;
});
}
// Switch view // Switch view
function showView(view) { function showView(view) {
@ -942,7 +961,11 @@
// Utility functions // Utility functions
function sortIdeasByVotes() { function sortIdeasByVotes() {
if (!currentTour) return; if (!currentTour) return;
currentTour.ideas.sort((a, b) => b.voters.length - a.voters.length); currentTour.ideas.sort((a, b) => {
if (a.decided) return -1;
if (b.decided) return 1;
return b.voters.length - a.voters.length;
});
} }
function generateUUID() { function generateUUID() {
@ -967,13 +990,30 @@
const voteSubmitBtn = document.getElementById('voteSubmitBtn'); const voteSubmitBtn = document.getElementById('voteSubmitBtn');
let _pendingVoteIdeaId = null; let _pendingVoteIdeaId = null;
function voteForIdea(ideaId) { function voteForIdea(ideaId) {
_pendingVoteIdeaId = ideaId; _pendingVoteIdeaId = ideaId;
voterNameInput.value = ''; voterNameInput.value = '';
voteSubmitBtn.disabled = true; voteSubmitBtn.disabled = true;
voteModal.classList.remove('hidden'); voteModal.classList.remove('hidden');
setTimeout(() => voterNameInput.focus(), 100); setTimeout(() => voterNameInput.focus(), 100);
} }
async function decideIdea(ideaId) {
if (!confirm('Mark this idea as decided?')) return;
showLoader();
try {
const resp = await fetch(`${API_URL}/tours/${currentTour.id}/ideas/${ideaId}/decide`, { method: 'POST' });
if (!resp.ok) throw new Error('Failed');
const decidedIdea = await resp.json();
currentTour.ideas = currentTour.ideas.map(i => i.id === decidedIdea.id ? decidedIdea : i);
renderIdeas();
} catch (err) {
console.error('Decide error', err);
alert('Failed to decide idea');
} finally {
hideLoader();
}
}
voterNameInput.addEventListener('input', () => { voterNameInput.addEventListener('input', () => {
voteSubmitBtn.disabled = voterNameInput.value.trim().length === 0; voteSubmitBtn.disabled = voterNameInput.value.trim().length === 0;

View File

@ -20,6 +20,7 @@ class Idea(BaseModel):
end_time: Optional[datetime] = None end_time: Optional[datetime] = None
description: str description: str
voters: list[str] = Field(default_factory=list) voters: list[str] = Field(default_factory=list)
decided: bool = False
class Tour(BaseModel): class Tour(BaseModel):
id: str id: str
@ -127,6 +128,9 @@ def add_idea(tour_id: str, idea: IdeaCreate):
with open(filepath) as f: with open(filepath) as f:
data = json.load(f) data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
idea_id = str(uuid.uuid4()) idea_id = str(uuid.uuid4())
new_idea = Idea( new_idea = Idea(
id=idea_id, id=idea_id,
@ -135,6 +139,7 @@ def add_idea(tour_id: str, idea: IdeaCreate):
voters=[], voters=[],
start_time=idea.start_time, start_time=idea.start_time,
end_time=idea.end_time, end_time=idea.end_time,
decided=False,
) )
data["ideas"].append(new_idea.model_dump()) data["ideas"].append(new_idea.model_dump())
@ -159,6 +164,8 @@ def vote_idea(tour_id: str, idea_id: str, vote: Vote):
with open(filepath) as f: with open(filepath) as f:
data = json.load(f) data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
for idea in data["ideas"]: for idea in data["ideas"]:
if idea["id"] == idea_id: if idea["id"] == idea_id:
if vote.voterName not in idea["voters"]: if vote.voterName not in idea["voters"]:
@ -170,6 +177,32 @@ def vote_idea(tour_id: str, idea_id: str, vote: Vote):
raise HTTPException(status_code=404, detail="Idea not found") raise HTTPException(status_code=404, detail="Idea not found")
# ─── ENDPOINT: DECIDE IDEA ────────────────────────────────────────────────────
@app.post("/tour/v1/tours/{tour_id}/ideas/{idea_id}/decide", response_model=Idea)
def decide_idea(tour_id: str, idea_id: str):
"""Mark an idea as decided. Only one idea can be decided per tour."""
filepath = get_tour_filepath(tour_id)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Tour not found")
lock = FileLock(filepath + ".lock")
with lock:
with open(filepath) as f:
data = json.load(f)
if any(i.get("decided") for i in data["ideas"]):
raise HTTPException(status_code=403, detail="Idea already decided")
for idea in data["ideas"]:
if idea["id"] == idea_id:
idea["decided"] = True
with open(filepath, "w") as f:
json.dump(data, f, default=str)
return Idea(**idea)
raise HTTPException(status_code=404, detail="Idea not found")
# ─── MAIN: RUN UVIDORN WITH COMMAND-LINE DATA DIR ────────────────────────────── # ─── MAIN: RUN UVIDORN WITH COMMAND-LINE DATA DIR ──────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":