Merge pull request #3 from ok2/codex/add-feature-to-prioritize--decided--ideas
Implement finalized idea support
This commit is contained in:
commit
5a1f4ca7fa
118
index.html
118
index.html
@ -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 form‐group down */
|
margin-bottom: 20px; /* pushes the next form‐group 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;
|
||||||
|
|||||||
33
server.py
33
server.py
@ -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__":
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user