diff --git a/src/app/App.tsx b/src/app/App.tsx index 9ed4bcd..0346ae5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,6 @@ import {AdminPage} from './routes/AdminPage'; import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage'; import {ProjectDetailsPage} from './routes/ProjectDetailsPage'; import {ProjectsPage} from './routes/ProjectsPage'; -import {VotingPage} from './routes/VotingPage'; import {YearAdministrationPage} from './routes/YearAdministrationPage'; import {ProjectVideoWatchPage, VideoWatchPage, WatchPage} from './routes/WatchPage'; import {YearsPage} from './routes/YearsPage'; @@ -72,7 +71,6 @@ export function App() { {session.user.role === 'admin' ? : } - diff --git a/src/app/components/ProjectCard.tsx b/src/app/components/ProjectCard.tsx index 37c4b96..3c1edb2 100644 --- a/src/app/components/ProjectCard.tsx +++ b/src/app/components/ProjectCard.tsx @@ -11,9 +11,11 @@ interface ProjectListMember { export function ProjectCard({ project, view = 'grid', + voteCategories = [], }: { project: ProjectSummary; view?: 'grid' | 'list'; + voteCategories?: string[]; }) { const projectLink = `/years/${project.yearId}/projects/${project.id}`; @@ -26,6 +28,7 @@ export function ProjectCard({ groupName={project.group?.name ?? 'ungrouped'} members={project.members} needsHelp={project.needsHelp} + voteCategories={voteCategories} /> ); } @@ -39,6 +42,7 @@ export function ProjectCard({ {project.summary}
+
); @@ -55,6 +59,7 @@ export function ProjectListItem({ members, needsHelp = false, emptyMemberLabel = 'up for grabs', + voteCategories = [], }: { name: string; href?: string; @@ -66,6 +71,7 @@ export function ProjectListItem({ members: ProjectListMember[]; needsHelp?: boolean; emptyMemberLabel?: string; + voteCategories?: string[]; }) { return (
@@ -86,6 +92,7 @@ export function ProjectListItem({
{groupName} {detail && {detail}} + {needsHelp && looking for help}
@@ -93,6 +100,20 @@ export function ProjectListItem({ ); } +function ProjectVoteBadge({categories}: {categories: string[]}) { + if (!categories.length) return null; + const count = categories.length; + return ( + + your picks · {count} + + ); +} + function ProjectTags({project, className}: {project: ProjectSummary; className: string}) { return (
diff --git a/src/app/components/ProjectVoting.tsx b/src/app/components/ProjectVoting.tsx new file mode 100644 index 0000000..75be3a8 --- /dev/null +++ b/src/app/components/ProjectVoting.tsx @@ -0,0 +1,198 @@ +import {useState} from 'react'; + +import type { + AwardCategorySummary, + BallotSelection, + BallotStatusResponse, +} from '../../shared/administration'; +import {useVoteMutation} from '../queries/administration'; + +export function ProjectVoting({ + ballot, + project, +}: { + ballot: BallotStatusResponse; + project: {id: string; name: string; yearId: string; canVote: boolean}; +}) { + const vote = useVoteMutation(project.yearId); + const [confirmingCategoryId, setConfirmingCategoryId] = useState(null); + const [statusMessage, setStatusMessage] = useState(null); + const pendingCategory = ballot.categories.find( + (category) => category.id === vote.variables?.input.categoryId, + ); + + function submit(category: AwardCategorySummary, selection?: BallotSelection) { + setStatusMessage(null); + vote.reset(); + vote.mutate( + { + voteId: selection?.id, + input: { + yearId: project.yearId, + projectId: project.id, + categoryId: category.id, + }, + }, + { + onSuccess: () => { + setConfirmingCategoryId(null); + setStatusMessage(`your ${category.name} vote is now on ${project.name}.`); + }, + }, + ); + } + + return ( +
+
+
+

award ballot

+

vote for this project

+
+

+ choose the award categories where {project.name} stands out. each category gets + one project. +

+
+ + {!project.canVote && ( +
+ your project sits this one out +

+ creators and teammates can’t vote for their own work, but your ballot is still + open on every other project. +

+
+ )} + + {!ballot.categories.length ? ( +

+ award categories are still being set up. check back soon. +

+ ) : ( +
    + {ballot.categories.map((category, index) => { + const selection = ballot.votes.find( + (item) => item.categoryId === category.id, + ); + const selectedHere = selection?.projectId === project.id; + const selectedElsewhere = Boolean(selection && !selectedHere); + const confirming = selectedElsewhere && confirmingCategoryId === category.id; + const pending = + vote.isPending && vote.variables?.input.categoryId === category.id; + const state = !project.canVote + ? 'unavailable' + : selectedHere + ? 'selected' + : selectedElsewhere + ? 'elsewhere' + : 'open'; + + return ( +
  • + +
    +

    {category.name}

    + {!project.canVote ? ( +

    unavailable on your own project

    + ) : selectedHere ? ( +

    + your vote +

    + ) : selection ? ( +

    + currently on {selection.projectName} + {!selection.projectActive && ' (project withdrawn)'} +

    + ) : ( +

    no project selected yet

    + )} +
    + + {project.canVote && !selectedHere && !confirming && ( + + )} + + {confirming && selection && ( +
    +

    + move your {category.name} vote from{' '} + {selection.projectName} to{' '} + {project.name}? +

    +
    + + +
    +
    + )} +
  • + ); + })} +
+ )} + + {vote.isPending && ( +

+ {vote.variables?.voteId ? 'moving' : 'casting'} your{' '} + {pendingCategory?.name ?? 'award'} vote… +

+ )} + {statusMessage && ( +

+ {statusMessage} +

+ )} + {vote.error && ( +

+ {vote.error.message} +

+ )} +
+ ); +} diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts index 5161b26..285d7bf 100644 --- a/src/app/queries/administration.ts +++ b/src/app/queries/administration.ts @@ -5,19 +5,22 @@ import type { AnalyticsResponse, AwardSummary, AwardWriteRequest, + BallotStatusResponse, ScreeningOrderItem, VoteSummary, VoteWriteRequest, - VotingResponse, YearWriteRequest, } from '../../shared/administration'; import {apiRequest, jsonRequest} from './api'; -export function useVoting(yearId: string) { +const ballotStatusQueryKey = (yearId: string) => ['ballot-status', yearId] as const; + +export function useBallotStatus(yearId: string, enabled = true) { return useQuery({ - queryKey: ['voting', yearId], + queryKey: ballotStatusQueryKey(yearId), queryFn: () => - apiRequest(`/votes?year=${encodeURIComponent(yearId)}`), + apiRequest(`/votes?year=${encodeURIComponent(yearId)}`), + enabled, }); } @@ -29,7 +32,7 @@ export function useVoteMutation(yearId: string) { voteId ? `/votes/${encodeURIComponent(voteId)}` : '/votes', jsonRequest(voteId ? 'PUT' : 'POST', input), ), - onSuccess: () => void cache.invalidateQueries({queryKey: ['voting', yearId]}), + onSettled: () => cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), }); } @@ -45,7 +48,7 @@ export function useAdminMutations(yearId: string) { const cache = useQueryClient(); const refresh = () => { void cache.invalidateQueries({queryKey: ['admin-year', yearId]}); - void cache.invalidateQueries({queryKey: ['voting', yearId]}); + void cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}); void cache.invalidateQueries({queryKey: ['year', yearId]}); void cache.invalidateQueries({queryKey: ['years']}); }; @@ -69,14 +72,6 @@ export function useAdminMutations(yearId: string) { apiRequest(`/admin/categories/${encodeURIComponent(id)}`, {method: 'DELETE'}), onSuccess: refresh, }); - const nominations = useMutation({ - mutationFn: ({projectId, categoryIds}: {projectId: string; categoryIds: string[]}) => - apiRequest( - `/admin/projects/${encodeURIComponent(projectId)}/nominations`, - jsonRequest('PUT', {categoryIds}), - ), - onSuccess: refresh, - }); const award = useMutation({ mutationFn: ({id, input}: {id?: string; input: AwardWriteRequest}) => apiRequest<{award: AwardSummary}>( @@ -100,7 +95,7 @@ export function useAdminMutations(yearId: string) { ), onSuccess: refresh, }); - return {year, category, removeCategory, nominations, award, removeAward, screening}; + return {year, category, removeCategory, award, removeAward, screening}; } export function useAnalytics(yearId?: string) { diff --git a/src/app/routes/AdminPage.tsx b/src/app/routes/AdminPage.tsx index 8d100d5..ea8a289 100644 --- a/src/app/routes/AdminPage.tsx +++ b/src/app/routes/AdminPage.tsx @@ -2,7 +2,6 @@ import {useEffect, useState} from 'react'; import type {FormEvent} from 'react'; import {Link, useParams} from 'wouter'; -import type {AdminProjectSummary} from '../../shared/administration'; import {QueryState} from '../components/AppLayout'; import {useAdminMutations, useAdminYear} from '../queries/administration'; @@ -35,7 +34,6 @@ export function AdminPage() { actions.year, actions.category, actions.removeCategory, - actions.nominations, actions.award, actions.removeAward, actions.screening, @@ -119,20 +117,6 @@ export function AdminPage() { ))} -
-

Eligibility

-

Project nominations

- {query.data.projects.map((project) => ( - - actions.nominations.mutate({projectId: project.id, categoryIds}) - } - /> - ))} -

Results

Awards

@@ -276,51 +260,6 @@ export function AdminPage() { ); } -function NominationEditor({ - project, - categories, - onSave, -}: { - project: AdminProjectSummary; - categories: {id: string; name: string}[]; - onSave: (ids: string[]) => void; -}) { - const [selected, setSelected] = useState( - project.nominations.map(({categoryId}) => categoryId), - ); - useEffect( - () => setSelected(project.nominations.map(({categoryId}) => categoryId)), - [project.nominations], - ); - return ( -
- {project.name} -
- {categories.map((category) => ( - - ))} -
- -
- ); -} - function move(items: string[], from: number, to: number) { const next = [...items]; const [item] = next.splice(from, 1); diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index 8f82d7b..49ecf4f 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -4,6 +4,8 @@ import {Link, useLocation, useParams} from 'wouter'; import {QueryState} from '../components/AppLayout'; import {Markdown} from '../components/Markdown'; +import {ProjectVoting} from '../components/ProjectVoting'; +import {useBallotStatus} from '../queries/administration'; import {getPlayback, useProjectVideo} from '../queries/videos'; import {ProjectVideoPanel} from '../video/ProjectVideoPanel'; import { @@ -20,6 +22,8 @@ export function ProjectDetailsPage() { }>(); const [, navigate] = useLocation(); const project = useProject(projectId); + const ballotYearId = project.data?.project.yearId ?? yearId; + const ballot = useBallotStatus(ballotYearId, project.data?.project.kind === 'project'); const withdraw = useDeleteProject(); const upload = useUploadMedia(projectId); const removeMedia = useDeleteMedia(projectId); @@ -144,6 +148,46 @@ export function ProjectDetailsPage() {
+ {project.data.project.kind === 'project' && ballot.isLoading && ( +
+

award ballot

+

loading voting status…

+
+ )} + {project.data.project.kind === 'project' && ballot.error && ( +
+

award ballot

+

voting status unavailable

+

{ballot.error.message}

+ +
+ )} + {project.data.project.kind === 'project' && + !ballot.error && + ballot.data?.year.votingEnabled && ( + + )} {project.data.project.kind === 'project' && ( (getProjectsView); const year = useYear(yearId); + const ballot = useBallotStatus(yearId, year.data?.year.votingEnabled ?? false); const projects = useProjects( yearId, kind, @@ -44,6 +47,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { search || undefined, ); const error = year.error ?? projects.error; + const voteCategoriesByProject = selectedCategoriesByProject(ballot.data); useEffect(() => { const timeout = window.setTimeout(() => { @@ -81,11 +85,6 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { watch reel )} - {year.data.year.votingEnabled && ( - - vote - - )} {isAdmin && ( manage year @@ -98,6 +97,14 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { )} + {year.data.year.votingEnabled && ( + + )}
{projects.data.projects.map((project) => ( - + ))} )} @@ -224,3 +236,130 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ); } + +function selectedCategoriesByProject(ballot?: BallotStatusResponse) { + const result = new Map(); + if (!ballot) return result; + const categoryNames = new Map( + ballot.categories.map((category) => [category.id, category.name]), + ); + for (const vote of ballot.votes) { + const categoryName = categoryNames.get(vote.categoryId); + if (!categoryName || !vote.projectActive) continue; + const categories = result.get(vote.projectId) ?? []; + categories.push(categoryName); + result.set(vote.projectId, categories); + } + return result; +} + +function BallotOverview({ + yearId, + data, + error, + loading, +}: { + yearId: string; + data?: BallotStatusResponse; + error: Error | null; + loading: boolean; +}) { + if (loading) { + return ( +
+
+

your ballot

+

counting your picks…

+
+

you can keep browsing while your progress loads.

+
+ ); + } + + if (error) { + return ( +
+
+

your ballot

+

progress is taking a break

+
+

we couldn't load your picks, but every project is still here to explore.

+
+ ); + } + + if (!data?.year.votingEnabled) return null; + + const selections = data.categories.flatMap((category) => { + const vote = data.votes.find((item) => item.categoryId === category.id); + return vote ? [{category, vote}] : []; + }); + const categoryCount = data.categories.length; + const castCount = data.votes.filter((vote) => vote.projectActive).length; + const inactiveCount = selections.filter(({vote}) => !vote.projectActive).length; + const remainingCount = Math.max(categoryCount - castCount, 0); + const complete = categoryCount > 0 && remainingCount === 0; + let message = 'open a project to cast your first vote.'; + if (categoryCount === 0) { + message = 'award categories are still being set up. check back soon.'; + } else if (complete) { + message = 'ballot complete — every category has your pick.'; + } else if (inactiveCount > 0) { + message = `${inactiveCount} withdrawn ${inactiveCount === 1 ? 'pick needs' : 'picks need'} a new project — ${remainingCount} ${remainingCount === 1 ? 'vote' : 'votes'} left to cast.`; + } else if (castCount > 0) { + message = `keep exploring — ${remainingCount} ${remainingCount === 1 ? 'vote' : 'votes'} left to cast.`; + } + + return ( +
+
+

voting is open

+

your ballot

+

{message}

+
+ + {castCount} {castCount === 1 ? 'vote' : 'votes'} cast + + + {remainingCount}{' '} + {remainingCount === 1 ? 'vote' : 'votes'} remaining + +
+ + {castCount} of {categoryCount} + + + {castCount} of {categoryCount} categor{categoryCount === 1 ? 'y' : 'ies'} + +
+
+

{selections.length ? 'your picks so far' : 'where to begin'}

+ {selections.length ? ( +
    + {selections.map(({category, vote}) => ( +
  • + {category.name} + {vote.projectActive ? ( + + {vote.projectName} + + ) : ( + + {vote.projectName} + project withdrawn — choose another project + + )} +
  • + ))} +
+ ) : ( +

+ {categoryCount + ? 'open any project that catches your eye and choose a category there.' + : 'once categories are ready, project pages will be the place to vote.'} +

+ )} +
+
+ ); +} diff --git a/src/app/routes/VotingPage.tsx b/src/app/routes/VotingPage.tsx deleted file mode 100644 index bbb02e4..0000000 --- a/src/app/routes/VotingPage.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import {useState} from 'react'; -import {Link, useParams} from 'wouter'; - -import {QueryState} from '../components/AppLayout'; -import {Markdown} from '../components/Markdown'; -import {useVoteMutation, useVoting} from '../queries/administration'; - -export function VotingPage() { - const {yearId} = useParams<{yearId: string}>(); - const query = useVoting(yearId); - const vote = useVoteMutation(yearId); - const [search, setSearch] = useState(''); - - return ( -
- - ← Projects - -
-
-

Hackweek {yearId}

-

vote for projects

-
-

- choose one project per award category. choosing again moves your vote; you - cannot vote for your own project. -

-
- - {query.data && ( - <> -
- - {query.data.year.votingEnabled ? 'Voting is open' : 'Voting is closed'} - - -
- {!query.data.categories.length ? ( -

No award categories are configured.

- ) : ( -
- {query.data.categories.map((category) => { - const current = query.data.votes.find( - (item) => item.categoryId === category.id, - ); - const projects = query.data.projects.filter((project) => { - const phrase = - `${project.name} ${project.memberNames.join(' ')}`.toLowerCase(); - return ( - phrase.includes(search.toLowerCase()) && - (project.nominations.length === 0 || - project.nominations.some( - (nomination) => nomination.categoryId === category.id, - )) - ); - }); - return ( -
-
-

Award category

-

{category.name}

-
-
- {projects.map((project) => { - const selected = current?.projectId === project.id; - return ( -
-
- {project.groupName ?? 'Independent'} -

{project.name}

- {project.summary} -
-
- {project.memberNames.join(' · ')} - -
-
- ); - })} -
-
- ); - })} -
- )} - {vote.error && ( -

- {vote.error.message} -

- )} - - )} -
-
- ); -} diff --git a/src/app/routes/YearsPage.tsx b/src/app/routes/YearsPage.tsx index 689f875..12615c6 100644 --- a/src/app/routes/YearsPage.tsx +++ b/src/app/routes/YearsPage.tsx @@ -67,8 +67,7 @@ export function YearsPage() { className="currentYearAction" href={`/years/${currentYear.id}/projects`} > - {currentYear.submissionsClosed ? 'view archive' : 'submissions open'}{' '} - → + {yearActionLabel(currentYear)} →
@@ -99,10 +98,7 @@ export function YearsPage() { {year.ideaCount > 0 ? ` · ${year.ideaCount} ideas` : ''} - - {year.submissionsClosed ? 'view archive' : 'submissions open'}{' '} - → - + {yearActionLabel(year)} → @@ -119,6 +115,11 @@ export function YearsPage() { ); } +function yearActionLabel(year: {votingEnabled: boolean; submissionsClosed: boolean}) { + if (year.votingEnabled) return 'voting open'; + return year.submissionsClosed ? 'view archive' : 'submissions open'; +} + function YearBanner({yearId}: {yearId: string}) { const banner = yearBanners[yearId]; return banner ? ( diff --git a/src/app/styles.css b/src/app/styles.css index 76ff0db..865871d 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -567,6 +567,183 @@ main { background: var(--danger); } +.ballotOverview { + position: relative; + display: grid; + grid-template-columns: minmax(15rem, 0.78fr) minmax(0, 1.22fr); + gap: clamp(1.5rem, 4vw, 3.5rem); + overflow: hidden; + padding: clamp(1.5rem, 4vw, 2.25rem); + margin: 0 0 2.5rem; + color: #fff; + border-radius: 0.9rem; + background: + radial-gradient( + circle at 93% 15%, + rgba(255, 112, 188, 0.36) 0 5rem, + transparent 5.1rem + ), + var(--dark-blurple); + box-shadow: 0 14px 32px rgba(29, 17, 39, 0.16); + animation: rise 0.35s ease-out both; +} +.ballotOverview::after { + position: absolute; + right: -1.5rem; + bottom: -2.5rem; + width: 8rem; + height: 8rem; + content: ''; + border: 1.25rem solid rgba(255, 255, 255, 0.08); + border-radius: 50%; + pointer-events: none; +} +.ballotOverview .kicker { + color: var(--pink); +} +.ballotOverview h2, +.ballotOverview h3, +.ballotOverview p { + margin-top: 0; +} +.ballotOverview h2 { + margin-bottom: 0.7rem; + font-size: clamp(1.65rem, 4vw, 2.35rem); + line-height: 1; + letter-spacing: -0.045em; +} +.ballotOverviewProgress > p:last-of-type { + max-width: 28rem; + margin-bottom: 1.35rem; + color: rgba(255, 255, 255, 0.76); + font-size: 0.88rem; + line-height: 1.55; +} +.ballotCounts { + display: flex; + gap: 1.5rem; + margin-bottom: 0.85rem; +} +.ballotCounts strong { + font-size: 1.35rem; + font-weight: 600; +} +.ballotCounts span { + color: rgba(255, 255, 255, 0.6); + font-size: 0.7rem; + font-weight: 500; +} +.ballotOverview progress { + display: block; + width: 100%; + height: 0.55rem; + overflow: hidden; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + appearance: none; +} +.ballotOverview progress::-webkit-progress-bar { + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); +} +.ballotOverview progress::-webkit-progress-value { + border-radius: 999px; + background: var(--green); +} +.ballotOverview progress::-moz-progress-bar { + border-radius: 999px; + background: var(--green); +} +.ballotOverviewProgress small { + display: block; + margin-top: 0.45rem; + color: rgba(255, 255, 255, 0.58); + font-size: 0.68rem; +} +.ballotSelections { + position: relative; + z-index: 1; + align-self: center; +} +.ballotSelections h3 { + margin-bottom: 0.8rem; + color: rgba(255, 255, 255, 0.64); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.ballotSelections ul { + padding: 0; + margin: 0; + list-style: none; +} +.ballotSelections li { + display: grid; + grid-template-columns: minmax(7rem, 0.7fr) minmax(0, 1.3fr); + gap: 1rem; + align-items: baseline; + padding: 0.72rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.18); +} +.ballotSelections li > span { + color: rgba(255, 255, 255, 0.62); + font-size: 0.75rem; +} +.ballotSelections a { + min-width: 0; + overflow: hidden; + color: #fff; + font-size: 0.88rem; + font-weight: 600; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} +.ballotSelections a:hover { + color: var(--green); +} +.ballotSelectionInactive { + text-align: right; +} +.ballotSelectionInactive strong, +.ballotSelectionInactive small { + display: block; +} +.ballotSelectionInactive strong { + color: #fff; + font-size: 0.88rem; +} +.ballotSelectionInactive small { + margin-top: 0.2rem; + color: rgba(255, 255, 255, 0.62); + font-size: 0.68rem; +} +.ballotSelections > p { + max-width: 30rem; + margin-bottom: 0; + color: rgba(255, 255, 255, 0.74); + font-size: 0.88rem; + line-height: 1.6; +} +.ballotOverview--notice { + grid-template-columns: minmax(14rem, 0.6fr) minmax(0, 1fr); + align-items: end; + padding-block: 1.5rem; + background: var(--dark-blurple); +} +.ballotOverview--notice h2 { + margin-bottom: 0; + font-size: 1.4rem; +} +.ballotOverview--notice > p { + margin-bottom: 0; + color: rgba(255, 255, 255, 0.74); + font-size: 0.85rem; + line-height: 1.55; +} + .projectControls, .operationsBar { display: flex; @@ -751,6 +928,24 @@ main { font-size: 0.65rem; white-space: nowrap; } +.projectVoteBadge { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 0.3rem 0.55rem; + color: #305500; + font-size: 0.66rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + border: 1px solid #b8db78; + border-radius: 999px; + background: #f0ffd7; +} +.projectRow .projectVoteBadge { + padding: 0.27rem 0.5rem; + font-size: 0.65rem; +} .projectRow .memberStack > span { width: 1.65rem; height: 1.65rem; @@ -940,8 +1135,7 @@ main { } .groupManager li, .adminList li, -.orderList li, -.nominationRow { +.orderList li { display: flex; gap: 1rem; align-items: center; @@ -1025,10 +1219,231 @@ main { gap: clamp(2rem, 6vw, 5rem); padding: clamp(2rem, 5vw, 4rem) 0; } +.projectVoting { + position: relative; + padding: clamp(1.5rem, 4vw, 2.5rem); + margin: 0 0 clamp(2.5rem, 6vw, 4.5rem); + border: 1px solid #d2bfff; + border-radius: 0.9rem; + background: + linear-gradient(90deg, rgba(117, 83, 255, 0.07) 1px, transparent 1px) 0 0 / 2.75rem + 100%, + #fcfaff; + box-shadow: 0 14px 32px rgba(78, 42, 154, 0.08); + animation: rise 0.35s ease-out both; +} +.projectVoting::before { + position: absolute; + top: -1px; + right: 1.5rem; + width: 5rem; + height: 0.35rem; + content: ''; + background: var(--pink); +} +.projectVoting--notice { + display: grid; + gap: 0.75rem; +} +.projectVoting--notice h2, +.projectVoting--notice p { + margin: 0; +} +.projectVoting--notice h2 { + font-size: clamp(1.5rem, 3vw, 2rem); +} +.projectVoting--notice > p:not(.kicker) { + color: var(--danger); +} +.projectVoting--notice .textAction { + justify-self: start; +} +.projectVoting > header { + display: grid; + grid-template-columns: minmax(13rem, 0.8fr) minmax(0, 1.2fr); + gap: clamp(1.5rem, 5vw, 4rem); + align-items: end; + padding-bottom: 1.5rem; +} +.projectVoting > header h2 { + margin: 0; + font-size: clamp(1.8rem, 4vw, 2.65rem); + line-height: 1; + letter-spacing: -0.045em; +} +.projectVoting > header > p { + max-width: 32rem; + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.65; +} +.projectVotingOwn { + display: grid; + grid-template-columns: minmax(12rem, 0.65fr) minmax(0, 1.35fr); + gap: 1.5rem; + align-items: baseline; + padding: 1rem 1.15rem; + margin-bottom: 1rem; + color: #56380a; + border: 1px solid #ead07f; + border-radius: 0.65rem; + background: #fff9df; +} +.projectVotingOwn strong { + font-size: 0.88rem; +} +.projectVotingOwn p { + margin: 0; + font-size: 0.8rem; + line-height: 1.55; +} +.projectVotingEmpty { + padding: 1.25rem; + margin: 0; + color: var(--muted); + border-top: 1px solid var(--line); +} +.projectVotingCategories { + padding: 0; + margin: 0; + list-style: none; + border-top: 1px solid var(--line); +} +.projectVotingCategory { + display: grid; + grid-template-columns: 2.5rem minmax(10rem, 1fr) auto; + gap: 1rem; + align-items: center; + min-height: 6rem; + padding: 1rem 1.1rem; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + border-left: 4px solid transparent; + background: rgba(255, 255, 255, 0.82); + transition: + border-color 120ms ease, + background 120ms ease; +} +.projectVotingCategory--selected { + border-left-color: var(--green); + background: #f5ffe5; +} +.projectVotingCategory--elsewhere { + border-left-color: var(--yellow); +} +.projectVotingCategory--unavailable { + border-left-color: #bdb4c8; + background: rgba(247, 245, 250, 0.9); +} +.projectVotingNumber { + align-self: start; + padding-top: 0.15rem; + color: #a59aad; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; +} +.projectVotingCategoryCopy h3 { + margin: 0 0 0.35rem; + font-size: 1.05rem; + line-height: 1.2; +} +.projectVotingCategoryCopy p { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; +} +.projectVotingCategoryCopy p strong { + color: var(--ink); +} +.projectVotingSelected { + display: inline-flex; + gap: 0.35rem; + align-items: center; + color: #305500 !important; +} +.projectVotingSelected::before { + content: '✓'; + font-size: 0.75rem; +} +.projectVotingCategory > button { + max-width: 17rem; +} +.projectVotingConfirm { + grid-column: 2 / -1; + display: grid; + grid-template-columns: minmax(12rem, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1rem; + margin-top: 0.15rem; + color: #56380a; + border: 1px solid #ead07f; + border-radius: 0.65rem; + background: #fff9df; +} +.projectVotingConfirm p { + margin: 0; + font-size: 0.82rem; + line-height: 1.55; +} +.projectVotingConfirm > div { + display: flex; + gap: 0.6rem; +} +.projectVotingConfirm .textAction, +.projectVotingConfirm .primaryAction { + min-height: 2.35rem; + padding: 0.55rem 0.75rem; + font-size: 0.75rem; +} +.projectVotingConfirm .projectVotingCancel { + color: var(--muted); + border-color: #bdb4c8; + background: #fff; +} +.projectVotingConfirm .projectVotingCancel:hover:not(:disabled) { + color: var(--ink); + border-color: var(--ink); + background: var(--soft); +} +.projectVotingConfirm .projectVotingMoveAction { + color: var(--ink); + border-color: var(--green); + background: var(--green); +} +.projectVotingConfirm .projectVotingMoveAction:hover:not(:disabled) { + color: #fff; + border-color: var(--dark-blurple); + background: var(--dark-blurple); +} +.projectVotingFeedback { + padding: 0.85rem 1rem; + margin: 1rem 0 0; + font-size: 0.82rem; + font-weight: 500; + border-radius: 0.55rem; +} +.projectVotingFeedback--pending { + color: var(--dark-blurple); + border: 1px solid #d2bfff; + background: var(--lavender); +} +.projectVotingFeedback--success { + color: #305500; + border: 1px solid #b8db78; + background: #f0ffd7; +} +.projectVotingFeedback--error { + color: #7a1426; + border: 1px solid #efb8c0; + background: #fff0f2; +} .projectNarrative > h2, .teamPanel h2, .mediaSection h2, -.ballotSection h2, .controlPanel h2, .resultsTable h2 { margin: 0 0 1.25rem; @@ -1731,63 +2146,6 @@ main { min-width: 14rem; margin-left: 0.5rem; } -.ballotSections { - width: 100%; -} -.ballotSection { - margin-bottom: 4rem; -} -.ballotSection > header { - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--line); -} -.ballotGrid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; - padding-top: 1rem; -} -.ballotCard { - display: flex; - min-height: 17rem; - padding: 1.25rem; - flex-direction: column; - border: 1px solid var(--line); - border-radius: 0.75rem; - background: #fff; -} -.ballotCard--selected { - border-color: var(--blurple); - background: #f7f4ff; - box-shadow: inset 0 0 0 1px var(--blurple); -} -.ballotCard small { - color: var(--dark-blurple); - font-weight: 600; -} -.ballotCard h3 { - margin: 1rem 0 0.5rem; - font-size: 1.35rem; -} -.ballotCard .markdown { - display: -webkit-box; - overflow: hidden; - line-height: 1.55; - -webkit-box-orient: vertical; - -webkit-line-clamp: 6; -} -.ballotCard footer { - display: flex; - gap: 1rem; - align-items: end; - justify-content: space-between; - margin-top: auto; -} -.ballotCard footer span { - color: var(--muted); - font-size: 0.75rem; -} - .adminGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1818,19 +2176,6 @@ main { .orderList { margin: 1.5rem 0; } -.nominationRow > div { - display: flex; - flex-wrap: wrap; - gap: 0.8rem; -} -.nominationRow label { - color: var(--muted); - font-size: 0.82rem; -} -.nominationRow input { - margin-right: 0.35rem; - accent-color: var(--blurple); -} .orderList li div { display: flex; gap: 0.35rem; @@ -2704,13 +3049,18 @@ kbd { .yearTimeline { gap: 2rem; } - .projectGrid, - .ballotGrid { + .projectGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detailLayout { grid-template-columns: 1fr; } + .projectVotingCategory { + grid-template-columns: 2rem minmax(9rem, 1fr) auto; + } + .projectVotingConfirm { + grid-column: 1 / -1; + } .teamPanel { padding: 2rem 0 0; border-top: 1px solid var(--line); @@ -2745,7 +3095,6 @@ kbd { } .yearTimeline, .projectGrid, - .ballotGrid, .adminGrid, .metricGrid { grid-template-columns: 1fr; @@ -2754,14 +3103,26 @@ kbd { grid-column: auto; } .detailHero, + .ballotOverview, .projectControls, .projectSearch, .operationsBar, - .groupManager > header, - .nominationRow { + .groupManager > header { align-items: stretch; flex-direction: column; } + .ballotOverview { + grid-template-columns: 1fr; + gap: 1.75rem; + } + .ballotSelections li { + grid-template-columns: 1fr; + gap: 0.25rem; + } + .ballotSelections a, + .ballotSelectionInactive { + text-align: left; + } .projectSearch > div { width: 100%; } @@ -2791,6 +3152,26 @@ kbd { .detailActions { min-width: 0; } + .projectVoting > header, + .projectVotingOwn, + .projectVotingCategory, + .projectVotingConfirm { + grid-template-columns: 1fr; + } + .projectVotingNumber { + display: none; + } + .projectVotingCategory > button { + width: 100%; + max-width: none; + } + .projectVotingConfirm { + grid-column: auto; + } + .projectVotingConfirm > div { + align-items: stretch; + flex-direction: column-reverse; + } .projectForm { grid-template-columns: 1fr; } diff --git a/src/shared/administration.ts b/src/shared/administration.ts index ffc2621..747b21a 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -4,21 +4,6 @@ export interface AwardCategorySummary { name: string; } -export interface NominationSummary { - categoryId: string; - position: 1 | 2; -} - -export interface VotingProject { - id: string; - name: string; - summary: string; - groupName: string | null; - memberNames: string[]; - nominations: NominationSummary[]; - eligible: boolean; -} - export interface VoteSummary { id: string; yearId: string; @@ -26,11 +11,15 @@ export interface VoteSummary { categoryId: string; } -export interface VotingResponse { +export interface BallotSelection extends VoteSummary { + projectName: string; + projectActive: boolean; +} + +export interface BallotStatusResponse { year: {id: string; votingEnabled: boolean}; categories: AwardCategorySummary[]; - projects: VotingProject[]; - votes: VoteSummary[]; + votes: BallotSelection[]; } export interface VoteWriteRequest { @@ -52,7 +41,6 @@ export interface AwardSummary { export interface AdminProjectSummary { id: string; name: string; - nominations: NominationSummary[]; videoStatus: import('./videos').VideoStatus | null; } @@ -90,10 +78,6 @@ export interface AwardWriteRequest { categoryId: string; } -export interface NominationsWriteRequest { - categoryIds: string[]; -} - export interface ScreeningOrderWriteRequest { projectIds: string[]; } diff --git a/src/shared/projects.ts b/src/shared/projects.ts index dd66d79..c288bdc 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -56,6 +56,7 @@ export interface ProjectDetail extends ProjectSummary { canDelete: boolean; canClaim: boolean; canManageMedia: boolean; + canVote: boolean; }; } diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index adb1d1d..e3d76ee 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -4,10 +4,10 @@ import type { AwardCategorySummary, AwardSummary, AwardWriteRequest, + BallotSelection, + BallotStatusResponse, ScreeningOrderItem, VoteSummary, - VotingProject, - VotingResponse, } from '../../shared/administration'; import {ServiceError} from '../services/errors'; import {getYear} from './projects'; @@ -18,91 +18,45 @@ interface CategoryRow { year_id: string; name: string; } -interface NominationRow { - project_id: string; - award_category_id: string; - position: 1 | 2; -} -interface ProjectRow { - id: string; - name: string; - summary: string | null; - group_name: string | null; - member_names: string | null; - eligible: number; -} - export async function getVoting( db: D1Database, yearId: string, userId: string, -): Promise { +): Promise { const year = await getYear(db, yearId); - const [categoryResult, projectResult, nominationResult, voteResult] = await Promise.all( - [ - db - .prepare( - `SELECT id, year_id, name FROM award_categories - WHERE year_id = ? ORDER BY name COLLATE NOCASE, id`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT p.id, p.name, p.summary, g.name group_name, - GROUP_CONCAT(u.display_name, ' · ') member_names, - CASE WHEN p.creator_id = ? OR EXISTS ( - SELECT 1 FROM project_members own - WHERE own.project_id = p.id AND own.user_id = ? - ) THEN 0 ELSE 1 END eligible - FROM projects p - LEFT JOIN groups g ON g.id = p.group_id - LEFT JOIN project_members pm ON pm.project_id = p.id - LEFT JOIN users u ON u.id = pm.user_id - WHERE p.year_id = ? AND p.kind = 'project' AND p.status = 'active' - GROUP BY p.id ORDER BY p.name COLLATE NOCASE, p.id`, - ) - .bind(userId, userId, yearId) - .all(), - db - .prepare( - `SELECT n.project_id, n.award_category_id, n.position - FROM project_nominations n - JOIN projects p ON p.id = n.project_id - WHERE p.year_id = ? ORDER BY n.project_id, n.position`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT id, year_id, project_id, award_category_id - FROM votes WHERE year_id = ? AND creator_id = ? ORDER BY award_category_id`, - ) - .bind(yearId, userId) - .all<{ - id: string; - year_id: string; - project_id: string; - award_category_id: string; - }>(), - ], - ); - const nominations = nominationsByProject(nominationResult.results); + const [categoryResult, voteResult] = await Promise.all([ + db + .prepare( + `SELECT id, year_id, name FROM award_categories + WHERE year_id = ? ORDER BY name COLLATE NOCASE, id`, + ) + .bind(yearId) + .all(), + db + .prepare( + `SELECT v.id, v.year_id, v.project_id, v.award_category_id, + p.name project_name, + p.year_id = v.year_id AND p.kind = 'project' AND p.status = 'active' + project_active + FROM votes v + JOIN projects p ON p.id = v.project_id + WHERE v.year_id = ? AND v.creator_id = ? + ORDER BY v.award_category_id`, + ) + .bind(yearId, userId) + .all<{ + id: string; + year_id: string; + project_id: string; + award_category_id: string; + project_name: string; + project_active: number; + }>(), + ]); return { year: {id: year.id, votingEnabled: year.votingEnabled}, categories: categoryResult.results.map(mapCategory), - projects: projectResult.results.map( - (row): VotingProject => ({ - id: row.id, - name: row.name, - summary: row.summary ?? '', - groupName: row.group_name, - memberNames: row.member_names ? row.member_names.split(' · ') : [], - nominations: nominations.get(row.id) ?? [], - eligible: Boolean(row.eligible), - }), - ), - votes: voteResult.results.map(mapVote), + votes: voteResult.results.map(mapBallotSelection), }; } @@ -206,63 +160,53 @@ export async function getAdminYear( yearId: string, ): Promise { const year = await getYear(db, yearId); - const [categoryResult, awardResult, projectResult, nominationResult, orderResult] = - await Promise.all([ - db - .prepare( - 'SELECT id, year_id, name FROM award_categories WHERE year_id = ? ORDER BY name COLLATE NOCASE, id', - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT a.id, a.year_id, a.project_id, p.name project_name, + const [categoryResult, awardResult, projectResult, orderResult] = await Promise.all([ + db + .prepare( + 'SELECT id, year_id, name FROM award_categories WHERE year_id = ? ORDER BY name COLLATE NOCASE, id', + ) + .bind(yearId) + .all(), + db + .prepare( + `SELECT a.id, a.year_id, a.project_id, p.name project_name, a.category_id, c.name category_name, a.name FROM awards a JOIN projects p ON p.id = a.project_id JOIN award_categories c ON c.id = a.category_id WHERE a.year_id = ? ORDER BY c.name COLLATE NOCASE, a.id`, - ) - .bind(yearId) - .all<{ - id: string; - year_id: string; - project_id: string; - project_name: string; - category_id: string; - category_name: string; - name: string; - }>(), - db - .prepare( - `SELECT p.id, p.name, pv.status video_status FROM projects p + ) + .bind(yearId) + .all<{ + id: string; + year_id: string; + project_id: string; + project_name: string; + category_id: string; + category_name: string; + name: string; + }>(), + db + .prepare( + `SELECT p.id, p.name, pv.status video_status FROM projects p LEFT JOIN video_submissions pv ON pv.project_id = p.id AND pv.retired_at IS NULL WHERE p.year_id = ? AND p.kind = 'project' AND p.status = 'active' ORDER BY p.name COLLATE NOCASE, p.id`, - ) - .bind(yearId) - .all<{ - id: string; - name: string; - video_status: import('../../shared/videos').VideoStatus | null; - }>(), - db - .prepare( - `SELECT n.project_id, n.award_category_id, n.position - FROM project_nominations n JOIN projects p ON p.id = n.project_id - WHERE p.year_id = ? ORDER BY n.project_id, n.position`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT o.project_id, p.name project_name, o.position + ) + .bind(yearId) + .all<{ + id: string; + name: string; + video_status: import('../../shared/videos').VideoStatus | null; + }>(), + db + .prepare( + `SELECT o.project_id, p.name project_name, o.position FROM screening_order o JOIN projects p ON p.id = o.project_id WHERE o.year_id = ? ORDER BY o.position`, - ) - .bind(yearId) - .all<{project_id: string; project_name: string; position: number}>(), - ]); - const nominations = nominationsByProject(nominationResult.results); + ) + .bind(yearId) + .all<{project_id: string; project_name: string; position: number}>(), + ]); return { year: { id: year.id, @@ -276,7 +220,6 @@ export async function getAdminYear( id: project.id, name: project.name, videoStatus: project.video_status, - nominations: nominations.get(project.id) ?? [], })), screeningOrder: orderResult.results.map( (row): ScreeningOrderItem => ({ @@ -340,32 +283,6 @@ export async function deleteCategory(db: D1Database, id: string) { } } -export async function replaceNominations( - db: D1Database, - projectId: string, - categoryIds: string[], -) { - try { - await db.batch([ - db.prepare('DELETE FROM project_nominations WHERE project_id = ?').bind(projectId), - ...categoryIds.map((categoryId, index) => - db - .prepare( - `INSERT INTO project_nominations (project_id, award_category_id, position) - VALUES (?, ?, ?)`, - ) - .bind(projectId, categoryId, index + 1), - ), - ]); - } catch (error) { - throw administrationConstraint(error, 'Nominations could not be saved'); - } - return categoryIds.map((categoryId, index) => ({ - categoryId, - position: index === 0 ? 1 : 2, - })); -} - export async function createAward( db: D1Database, yearId: string, @@ -559,30 +476,24 @@ async function getAward(db: D1Database, id: string): Promise { return mapAward(row); } -function nominationsByProject(rows: NominationRow[]) { - const result = new Map(); - for (const row of rows) { - const list = result.get(row.project_id) ?? []; - list.push({categoryId: row.award_category_id, position: row.position}); - result.set(row.project_id, list); - } - return result; -} - function mapCategory(row: CategoryRow): AwardCategorySummary { return {id: row.id, yearId: row.year_id, name: row.name}; } -function mapVote(row: { +function mapBallotSelection(row: { id: string; year_id: string; project_id: string; award_category_id: string; -}): VoteSummary { + project_name: string; + project_active: number; +}): BallotSelection { return { id: row.id, yearId: row.year_id, projectId: row.project_id, + projectName: row.project_name, + projectActive: Boolean(row.project_active), categoryId: row.award_category_id, }; } @@ -617,7 +528,6 @@ function administrationConstraint(cause: unknown, fallback: string) { 'vote project must', 'vote category must', 'users cannot vote', - 'nomination category and project', 'award references must', 'screening entry must', 'FOREIGN KEY constraint failed', diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index 5c26a97..da3c738 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -249,6 +249,7 @@ export async function getProject( canClaim: !year.submissionsClosed && row.kind === 'idea' && projectMembers.length === 0, canManageMedia: canWrite && row.kind === 'project', + canVote: row.kind === 'project' && !isCreator && !isMember, }, }; } diff --git a/src/worker/routes/admin.ts b/src/worker/routes/admin.ts index e8174dd..0e0554b 100644 --- a/src/worker/routes/admin.ts +++ b/src/worker/routes/admin.ts @@ -8,14 +8,12 @@ import { createYear, deleteCategory, getAdminYear, - replaceNominations, replaceScreeningOrder, updateCategory, updateYear, } from '../repositories/administration'; import { parseNamed, - parseNominations, parseScreeningOrder, parseYear, } from '../services/administration-input'; @@ -77,18 +75,6 @@ adminRoutes.delete('/categories/:categoryId', async (c) => }), ); -adminRoutes.put('/projects/:projectId/nominations', async (c) => - run(c, async () => - c.json({ - nominations: await replaceNominations( - c.env.DB, - c.req.param('projectId'), - parseNominations(await c.req.json()).categoryIds, - ), - }), - ), -); - adminRoutes.put('/years/:yearId/screening-order', async (c) => run(c, async () => c.json({ diff --git a/src/worker/services/administration-input.ts b/src/worker/services/administration-input.ts index 62cf74d..588e15c 100644 --- a/src/worker/services/administration-input.ts +++ b/src/worker/services/administration-input.ts @@ -1,7 +1,6 @@ import type { AwardWriteRequest, NamedWriteRequest, - NominationsWriteRequest, ScreeningOrderWriteRequest, VoteWriteRequest, YearWriteRequest, @@ -51,18 +50,6 @@ export function parseAward(value: JsonInput): AwardWriteRequest { }; } -export function parseNominations(value: JsonInput): NominationsWriteRequest { - const body = record(value); - if (!Array.isArray(body.categoryIds) || body.categoryIds.length > 2) { - invalid('A project can have at most two nominations'); - } - const categoryIds = body.categoryIds.map((value) => identifier(value, 'Category')); - if (new Set(categoryIds).size !== categoryIds.length) { - invalid('Nomination categories must be distinct'); - } - return {categoryIds}; -} - export function parseScreeningOrder(value: JsonInput): ScreeningOrderWriteRequest { const body = record(value); if (!Array.isArray(body.projectIds)) invalid('Project order must be an array'); diff --git a/test/admin/admin.test.ts b/test/admin/admin.test.ts index 5adbe26..5232b99 100644 --- a/test/admin/admin.test.ts +++ b/test/admin/admin.test.ts @@ -98,40 +98,27 @@ describe('year and award administration', () => { expect(stored).toEqual({voting_enabled: 1, submissions_closed: 0}); }); - it('enforces two distinct same-year nominations in validation and D1', async () => { - const first = await createCategory('First'); - const second = await createCategory('Second'); - const third = await createCategory('Third'); - const saved = await api(`/admin/projects/${projectId}/nominations`, adminToken, { + it('does not expose project nomination administration', async () => { + const category = await createCategory('Unused nomination'); + const response = await SELF.fetch(`${base}/admin/projects/${projectId}/nominations`, { method: 'PUT', - body: {categoryIds: [first.id, second.id]}, + headers: { + Cookie: adminToken, + Origin: 'https://hackweek.test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({categoryIds: [category.id]}), }); - const duplicate = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [first.id, first.id]}, - }); - const tooMany = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [first.id, second.id, third.id]}, - }); - const otherYear = `${yearId}-other`; - await env.DB.prepare('INSERT INTO years (id) VALUES (?)').bind(otherYear).run(); - const crossId = `cross-category-${sequence}`; - await env.DB.prepare( - `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) - VALUES (?, ?, ?, ?, ?)`, + const state = await api(`/admin/years/${yearId}`, adminToken); + const nomination = await env.DB.prepare( + 'SELECT project_id FROM project_nominations WHERE project_id = ?', ) - .bind(crossId, crossId, otherYear, 'Cross', adminId) - .run(); - const cross = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [crossId]}, - }); + .bind(projectId) + .first(); - expect(saved.body.nominations).toHaveLength(2); - expect(duplicate.status).toBe(400); - expect(tooMany.status).toBe(400); - expect(cross.body.error.message).toMatch(/nomination/); + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(nomination).toBeNull(); + expect(state.body.projects[0]).not.toHaveProperty('nominations'); }); it('creates one same-year award per category and rejects invalid references', async () => { diff --git a/test/app/ProjectForm.test.tsx b/test/app/ProjectForm.test.tsx index b516bce..a95fbb8 100644 --- a/test/app/ProjectForm.test.tsx +++ b/test/app/ProjectForm.test.tsx @@ -198,5 +198,11 @@ const projectFixture: ProjectDetail = { members: [alice], mediaCount: 0, media: [], - permissions: {canEdit: true, canDelete: true, canClaim: false, canManageMedia: true}, + permissions: { + canEdit: true, + canDelete: true, + canClaim: false, + canManageMedia: true, + canVote: false, + }, }; diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 213430e..fde6191 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -1,65 +1,22 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import type {ReactNode} from 'react'; -import {render, screen, waitFor} from '@testing-library/react'; +import {act, render, screen, waitFor, within} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {Route, Router} from 'wouter'; import {memoryLocation} from 'wouter/memory-location'; import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ProjectVoting} from '../../src/app/components/ProjectVoting'; +import {useBallotStatus} from '../../src/app/queries/administration'; import {AdminAnalyticsPage} from '../../src/app/routes/AdminAnalyticsPage'; import {AdminPage} from '../../src/app/routes/AdminPage'; -import {VotingPage} from '../../src/app/routes/VotingPage'; +import type {BallotStatusResponse} from '../../src/shared/administration'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); afterEach(() => fetchMock.mockReset()); describe('voting and administration journeys', () => { - it('renders compact Markdown in voting cards', async () => { - fetchMock.mockResolvedValue( - json({ - ...votingFixture, - projects: [ - { - ...votingFixture.projects[0], - summary: '**Working** details at [the docs](https://example.com).', - }, - ], - }), - ); - renderRoute(, '/years/2026/vote', '/years/:yearId/vote'); - - expect((await screen.findByText('Working')).tagName).toBe('STRONG'); - const link = screen.getByRole('link', {name: 'the docs'}); - expect(link.closest('.markdown')?.classList.contains('markdown--compact')).toBe(true); - expect(link.getAttribute('target')).toBe('_blank'); - }); - - it('moves an existing vote to the selected project through the API', async () => { - fetchMock.mockImplementation(async (_input, init) => { - if (init?.method === 'PUT') return json({vote: {...vote, projectId: 'project-2'}}); - return json(votingFixture); - }); - renderRoute(, '/years/2026/vote', '/years/:yearId/vote'); - - expect(await screen.findByText('First project')).toBeTruthy(); - await userEvent.click(screen.getByRole('button', {name: 'Move vote'})); - - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - '/api/votes/vote-1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ - yearId: '2026', - projectId: 'project-2', - categoryId: 'category-1', - }), - }), - ), - ); - }); - it('renders admin controls and sends year/category changes to aggregate APIs', async () => { fetchMock.mockImplementation(async (input, init) => { const url = input instanceof Request ? input.url : input.toString(); @@ -72,6 +29,7 @@ describe('voting and administration journeys', () => { renderRoute(, '/admin/years/2026', '/admin/years/:yearId'); const submissions = await screen.findByRole('checkbox', {name: 'Submissions closed'}); + expect(screen.queryByRole('heading', {name: 'Project nominations'})).toBeNull(); await userEvent.click(submissions); await userEvent.type(screen.getByLabelText('Category name'), 'New category'); await userEvent.click(screen.getByRole('button', {name: 'Add category'})); @@ -115,6 +73,211 @@ describe('voting and administration journeys', () => { expect(votingEnabled.disabled).toBe(true); }); + it('casts a first vote and requires an explicit confirmed move', async () => { + let ballotReads = 0; + let ballot: BallotStatusResponse = { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [ + { + id: 'vote-impact', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'impact', + }, + { + id: 'vote-craft', + yearId: '2026', + projectId: 'other-project', + projectName: 'Quiet hours', + projectActive: true, + categoryId: 'craft', + }, + ], + }; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + return json(ballot); + } + if (url === '/api/votes' && init?.method === 'POST') { + const selection = { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'delight', + }; + ballot = {...ballot, votes: [...ballot.votes, selection]}; + return json({vote: selection}, 201); + } + if (url === '/api/votes/vote-craft' && init?.method === 'PUT') { + const selection = { + id: 'vote-craft', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'craft', + }; + ballot = { + ...ballot, + votes: ballot.votes.map((item) => + item.id === 'vote-craft' ? selection : item, + ), + }; + return json({vote: selection}); + } + throw new Error(`unexpected request: ${url}`); + }); + + renderRoute(, '/', '/'); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + const impactRow = within(voting).getByRole('heading', {name: 'Impact'}).closest('li'); + expect(impactRow).toBeTruthy(); + if (!(impactRow instanceof HTMLElement)) throw new Error(); + expect(within(impactRow).getByText('your vote')).toBeTruthy(); + expect(within(impactRow).queryByRole('button')).toBeNull(); + + await userEvent.click( + within(voting).getByRole('button', { + name: /vote for delight/i, + }), + ); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/votes' && init?.method === 'POST', + ); + expect(request).toBeTruthy(); + expect(request?.[1]?.body).toBe( + JSON.stringify({ + yearId: '2026', + projectId: 'project', + categoryId: 'delight', + }), + ); + expect(ballotReads).toBeGreaterThanOrEqual(2); + }); + expect( + await within(voting).findByText('your Delight vote is now on A small machine.'), + ).toBeTruthy(); + const delightRow = within(voting) + .getByRole('heading', {name: 'Delight'}) + .closest('li'); + expect(delightRow).toBeTruthy(); + if (!(delightRow instanceof HTMLElement)) throw new Error(); + expect(within(delightRow).getByText('your vote')).toBeTruthy(); + + await userEvent.click(within(voting).getByRole('button', {name: 'move vote here'})); + expect(within(voting).getByText(/move your Craft vote from/).textContent).toContain( + 'Quiet hours', + ); + await userEvent.click(within(voting).getByRole('button', {name: 'cancel'})); + expect( + fetchMock.mock.calls.some( + ([input, init]) => input === '/api/votes/vote-craft' && init?.method === 'PUT', + ), + ).toBe(false); + expect(within(voting).queryByRole('button', {name: 'confirm move'})).toBeNull(); + + await userEvent.click(within(voting).getByRole('button', {name: 'move vote here'})); + await userEvent.click(within(voting).getByRole('button', {name: 'confirm move'})); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/votes/vote-craft' && init?.method === 'PUT', + ); + expect(request).toBeTruthy(); + expect(request?.[1]?.body).toBe( + JSON.stringify({ + yearId: '2026', + projectId: 'project', + categoryId: 'craft', + }), + ); + expect(ballotReads).toBeGreaterThanOrEqual(3); + }); + expect( + await within(voting).findByText('your Craft vote is now on A small machine.'), + ).toBeTruthy(); + }); + + it('reports a pending first vote, keeps errors local, and reconciles conflicts', async () => { + let resolveVote!: (response: Response) => void; + const pendingVote = new Promise((resolve) => { + resolveVote = resolve; + }); + let ballot: BallotStatusResponse = { + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return json(ballot); + if (url === '/api/votes' && init?.method === 'POST') return pendingVote; + throw new Error(`unexpected request: ${url}`); + }); + + renderRoute(, '/', '/'); + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + await userEvent.click( + within(voting).getByRole('button', { + name: /vote for delight/i, + }), + ); + + const pending = await within(voting).findByRole('button', { + name: 'casting your vote…', + }); + expect(within(voting).getByRole('status').textContent).toContain( + 'casting your Delight vote…', + ); + expect(pending).toBeInstanceOf(HTMLButtonElement); + if (!(pending instanceof HTMLButtonElement)) throw new Error(); + expect(pending.disabled).toBe(true); + + await act(async () => { + ballot = { + ...ballot, + votes: [ + { + id: 'vote-delight', + yearId: '2026', + projectId: 'other-project', + projectName: 'Quiet hours', + projectActive: true, + categoryId: 'delight', + }, + ], + }; + resolveVote( + json( + {error: {code: 'VOTE_CONFLICT', message: 'This vote changed elsewhere'}}, + 409, + ), + ); + }); + + expect((await within(voting).findByRole('alert')).textContent).toContain( + 'This vote changed elsewhere', + ); + expect( + await within(voting).findByRole('button', {name: 'move vote here'}), + ).toBeTruthy(); + expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); + }); + it('renders D1 aggregate analytics without raw vote identities', async () => { fetchMock.mockResolvedValue( json({ @@ -150,6 +313,26 @@ describe('voting and administration journeys', () => { }); }); +function requestUrl(input: string | URL | Request) { + return input instanceof Request ? input.url : input instanceof URL ? input.href : input; +} + +function VotingHarness() { + const ballot = useBallotStatus('2026'); + if (!ballot.data) return null; + return ( + + ); +} + function renderRoute(element: ReactNode, path: string, pattern: string) { const client = new QueryClient({defaultOptions: {queries: {retry: false}}}); const {hook} = memoryLocation({path}); @@ -169,37 +352,6 @@ function json(value: T, status = 200) { }); } -const vote = { - id: 'vote-1', - yearId: '2026', - projectId: 'project-1', - categoryId: 'category-1', -}; -const votingFixture = { - year: {id: '2026', votingEnabled: true}, - categories: [{id: 'category-1', yearId: '2026', name: 'Delight'}], - projects: [ - { - id: 'project-1', - name: 'First project', - summary: 'One.', - groupName: 'Orbital', - memberNames: ['A'], - nominations: [{categoryId: 'category-1', position: 1}], - eligible: true, - }, - { - id: 'project-2', - name: 'Second project', - summary: 'Two.', - groupName: null, - memberNames: ['B'], - nominations: [{categoryId: 'category-1', position: 1}], - eligible: true, - }, - ], - votes: [vote], -}; const adminFixture = { year: { id: '2026', @@ -209,6 +361,6 @@ const adminFixture = { }, categories: [{id: 'category-1', yearId: '2026', name: 'Delight'}], awards: [], - projects: [{id: 'project-1', name: 'First project', nominations: []}], + projects: [{id: 'project-1', name: 'First project', videoStatus: null}], screeningOrder: [], }; diff --git a/test/app/auth.test.tsx b/test/app/auth.test.tsx index 772196e..829dd75 100644 --- a/test/app/auth.test.tsx +++ b/test/app/auth.test.tsx @@ -78,6 +78,44 @@ describe('Google sign-in experience', () => { expect(screen.getByRole('link', {name: 'Sign in with Google'})).toBeTruthy(); }); + it('does not register the retired standalone ballot route', async () => { + window.history.replaceState(null, '', '/years/2026/vote'); + fetchMock.mockImplementation(async (input) => { + const path = + input instanceof Request ? new URL(input.url).pathname : input.toString(); + if (path === '/api/session') { + return Response.json({ + user: { + id: 'member', + email: 'member@sentry.io', + displayName: 'Member One', + avatarUrl: null, + role: 'member', + actualRole: 'member', + }, + }); + } + return Response.json({years: []}); + }); + const queryClient = new QueryClient({ + defaultOptions: {queries: {retry: false}}, + }); + + const rendered = render( + + + + + , + ); + + expect( + await screen.findByRole('heading', {name: 'Lost in the archive'}), + ).toBeTruthy(); + expect(screen.queryByRole('heading', {name: 'vote for projects'})).toBeNull(); + rendered.unmount(); + }); + it('explains a failed fixed callback without reflecting arbitrary text', async () => { window.history.replaceState(null, '', '/?auth_error=failed&message=attacker'); fetchMock.mockResolvedValue( diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 24598e5..a9c7b7a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -11,6 +11,7 @@ import {ProjectCard} from '../../src/app/components/ProjectCard'; import {ProjectDetailsPage} from '../../src/app/routes/ProjectDetailsPage'; import {ProjectsPage} from '../../src/app/routes/ProjectsPage'; import {YearsPage} from '../../src/app/routes/YearsPage'; +import type {BallotStatusResponse} from '../../src/shared/administration'; import type {ProjectDetail} from '../../src/shared/projects'; const fetchMock = vi.fn(); @@ -107,6 +108,30 @@ describe('clickable project routes', () => { ); }); + it('shows voting open as the current-year action while voting is enabled', async () => { + fetchMock.mockResolvedValue( + json({ + years: [ + { + id: '2026', + votingEnabled: true, + submissionsClosed: false, + projectCount: 4, + ideaCount: 2, + groupCount: 1, + participantCount: 8, + }, + ], + }), + ); + + renderRoute(, '/years'); + + const hero = await screen.findByRole('region', {name: 'Hackweek 2026'}); + expect(within(hero).getByRole('link', {name: /voting open/})).toBeTruthy(); + expect(within(hero).queryByRole('link', {name: /submissions open/})).toBeNull(); + }); + it('promotes the latest year to the hero and renders earlier years as archives', async () => { fetchMock.mockResolvedValue( json({ @@ -172,7 +197,7 @@ describe('clickable project routes', () => { return json({ year: { id: '2026', - votingEnabled: false, + votingEnabled: true, submissionsClosed, projectCount: 0, ideaCount: 0, @@ -183,6 +208,13 @@ describe('clickable project routes', () => { awards: [], }); } + if (url.includes('/api/votes?')) { + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [], + votes: [], + }); + } return json({projects: [], nextCursor: null}); }); @@ -193,6 +225,7 @@ describe('clickable project routes', () => { ); expect(await screen.findByRole('heading', {name: 'projects & ideas'})).toBeTruthy(); expect(screen.queryByRole('link', {name: 'watch reel'})).toBeNull(); + expect(screen.queryByRole('link', {name: 'vote'})).toBeNull(); member.unmount(); const admin = renderRoute( @@ -208,6 +241,190 @@ describe('clickable project routes', () => { expect(await screen.findByRole('link', {name: 'watch reel'})).toBeTruthy(); }); + it('shows progress, links active picks, and identifies withdrawn picks', async () => { + mockProjectsOverview({ + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [ + { + id: 'vote-1', + yearId: '2026', + projectId: 'signal-forge', + projectName: 'Signal forge', + projectActive: true, + categoryId: 'delight', + }, + { + id: 'vote-2', + yearId: '2026', + projectId: 'quiet-hours', + projectName: 'Quiet hours', + projectActive: false, + categoryId: 'impact', + }, + ], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const ballot = await screen.findByRole('region', {name: 'your ballot'}); + const counts = within(ballot).getByLabelText('Ballot counts'); + expect(counts.textContent).toContain('1 vote cast'); + expect(counts.textContent).toContain('2 votes remaining'); + expect( + within(ballot).getByText( + '1 withdrawn pick needs a new project — 2 votes left to cast.', + ), + ).toBeTruthy(); + const progress = within(ballot).getByRole('progressbar', { + name: 'ballot progress', + }); + expect(progress.getAttribute('value')).toBe('1'); + expect(progress.getAttribute('max')).toBe('3'); + expect( + within(ballot) + .getByRole('link', {name: /Signal forge/}) + .getAttribute('href'), + ).toBe('/years/2026/projects/signal-forge'); + expect(within(ballot).queryByRole('link', {name: /Quiet hours/})).toBeNull(); + expect(within(ballot).getByText('Quiet hours')).toBeTruthy(); + expect( + within(ballot).getByText('project withdrawn — choose another project'), + ).toBeTruthy(); + expect(within(ballot).getAllByRole('link')).toHaveLength(1); + }); + + it('encourages a first vote and celebrates a completed ballot', async () => { + mockProjectsOverview({ + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + }); + const emptyBallot = renderRoute( + , + '/years/2026/projects', + '/years/:yearId/projects', + ); + + expect( + await screen.findByText('open a project to cast your first vote.'), + ).toBeTruthy(); + expect( + screen.getByText( + 'open any project that catches your eye and choose a category there.', + ), + ).toBeTruthy(); + emptyBallot.unmount(); + + fetchMock.mockReset(); + mockProjectsOverview({ + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [ + { + id: 'vote-1', + yearId: '2026', + projectId: 'signal-forge', + projectName: 'Signal forge', + projectActive: true, + categoryId: 'delight', + }, + ], + }); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect( + await screen.findByText('ballot complete — every category has your pick.'), + ).toBeTruthy(); + expect(screen.getByLabelText('Ballot counts').textContent).toContain( + '0 votes remaining', + ); + }); + + it('explains when open voting has no configured categories', async () => { + mockProjectsOverview({}); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect( + await screen.findByText( + 'award categories are still being set up. check back soon.', + ), + ).toBeTruthy(); + expect( + screen.getByText( + 'once categories are ready, project pages will be the place to vote.', + ), + ).toBeTruthy(); + const progress = screen.getByRole('progressbar', {name: 'ballot progress'}); + expect(progress.getAttribute('value')).toBe('0'); + }); + + it('marks personal vote counts in both project views', async () => { + mockProjectsOverview({ + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + ], + votes: [ + { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'delight', + }, + { + id: 'vote-impact', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'impact', + }, + ], + projects: [projectFixture], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const gridBadge = await screen.findByLabelText('2 of your picks: Delight, Impact'); + expect(gridBadge.textContent).toBe('your picks · 2'); + expect(gridBadge.closest('.projectCard')).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', {name: 'list view'})); + const listBadge = screen.getByLabelText('2 of your picks: Delight, Impact'); + expect(listBadge.closest('.projectRow')).toBeTruthy(); + }); + + it('keeps closed-year browsing and ballot read failures local', async () => { + mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); + const closed = renderRoute( + , + '/years/2026/projects', + '/years/:yearId/projects', + ); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'your ballot'})).toBeNull(); + expect( + fetchMock.mock.calls.some(([input]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url.includes('/api/votes?'); + }), + ).toBe(false); + closed.unmount(); + + fetchMock.mockReset(); + mockProjectsOverview({ballotError: true, projects: [projectFixture]}); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByText('progress is taking a break')).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); + }); + it('defaults to the grid view when storage is unavailable', async () => { fetchMock.mockImplementation(async (input) => { const url = input instanceof Request ? input.url : input.toString(); @@ -432,6 +649,261 @@ describe('clickable project routes', () => { }); }); + it('adds every open award category before project media and video', async () => { + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [], + }, + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + expect(within(voting).getByRole('heading', {name: 'Delight'})).toBeTruthy(); + expect(within(voting).getByRole('heading', {name: 'Impact'})).toBeTruthy(); + expect(within(voting).getByRole('heading', {name: 'Craft'})).toBeTruthy(); + expect(within(voting).getAllByRole('listitem')).toHaveLength(3); + + const video = screen.getByRole('region', {name: 'project video'}); + expect( + voting.compareDocumentPosition(video) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'attachments'})).toBeTruthy(); + }); + + it('uses the loaded project year for ballot state', async () => { + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }, + }); + + renderRoute( + , + '/years/2025/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect( + await screen.findByRole('region', {name: 'vote for this project'}), + ).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith('/api/votes?year=2026', undefined); + expect(fetchMock).not.toHaveBeenCalledWith('/api/votes?year=2025', undefined); + }); + + it('shows local ballot loading and retry states on project details', async () => { + let resolveBallot!: (response: Response) => void; + const pendingBallot = new Promise((resolve) => { + resolveBallot = resolve; + }); + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return pendingBallot; + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + const loading = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect( + await screen.findByRole('region', {name: 'loading voting status…'}), + ).toBeTruthy(); + resolveBallot( + json({ + year: {id: '2026', votingEnabled: false}, + categories: [], + votes: [], + }), + ); + await waitFor(() => + expect(screen.queryByRole('region', {name: 'loading voting status…'})).toBeNull(), + ); + loading.unmount(); + + fetchMock.mockReset(); + let ballotReads = 0; + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + if (ballotReads === 1) { + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }); + } + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const error = await screen.findByRole('region', { + name: 'voting status unavailable', + }); + expect(within(error).getByRole('alert').textContent).toContain('Ballot unavailable'); + await userEvent.click(within(error).getByRole('button', {name: 'try again'})); + expect( + await screen.findByRole('region', {name: 'vote for this project'}), + ).toBeTruthy(); + expect(ballotReads).toBe(2); + }); + + it('replaces stale voting controls when a ballot refresh fails', async () => { + let ballotReads = 0; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + if (ballotReads === 1) { + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }); + } + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + if (url === '/api/votes' && init?.method === 'POST') { + return json( + { + vote: { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + categoryId: 'delight', + }, + }, + 201, + ); + } + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + await userEvent.click(within(voting).getByRole('button', {name: 'vote for Delight'})); + + expect( + await screen.findByRole('region', {name: 'voting status unavailable'}), + ).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + expect(ballotReads).toBe(2); + }); + + it('explains unavailable own-project voting and hides controls when closed', async () => { + mockProjectDetails({ + detail: projectFixture, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + ], + votes: [], + }, + }); + + const ownProject = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + expect(within(voting).getByText('your project sits this one out')).toBeTruthy(); + expect(within(voting).getAllByText('unavailable on your own project')).toHaveLength( + 2, + ); + expect(within(voting).queryByRole('button')).toBeNull(); + ownProject.unmount(); + + fetchMock.mockReset(); + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: false}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }, + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await waitFor(() => + expect( + fetchMock.mock.calls.some(([input]) => requestUrl(input).includes('/api/votes?')), + ).toBe(true), + ); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + }); + it('renders compact Markdown in project cards without exposing block layout', () => { const summary = '# Overview\nFirst line\nSecond line with **detail** and a [link](https://example.com).'; @@ -447,15 +919,13 @@ describe('clickable project routes', () => { }); it('renders project descriptions as GitHub-flavored Markdown', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - summary: - 'Built with **care**. Visit https://example.com/docs.\nSecond line with [details](#details).\n\nUse safely.\n\n- [x] Links work', - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + summary: + 'Built with **care**. Visit https://example.com/docs.\nSecond line with [details](#details).\n\nUse safely.\n\n- [x] Links work', + }, + }); const rendered = renderRoute( , @@ -478,14 +948,12 @@ describe('clickable project routes', () => { }); it('sanitizes unsafe Markdown URLs and raw HTML', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - summary: '[unsafe](javascript:alert(1))\n\n', - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + summary: '[unsafe](javascript:alert(1))\n\n', + }, + }); const rendered = renderRoute( , @@ -500,31 +968,29 @@ describe('clickable project routes', () => { }); it('previews image attachments and opens the original in a new tab', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - media: [ - { - id: 'screenshot', - originalName: 'Launch screenshot.PNG', - mediaType: 'IMAGE/PNG', - sizeBytes: 2048, - status: 'available', - createdAt: '2026-01-02', - }, - { - id: 'notes', - originalName: 'Notes.txt', - mediaType: 'text/plain', - sizeBytes: 9, - status: 'available', - createdAt: '2026-01-03', - }, - ], - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + media: [ + { + id: 'screenshot', + originalName: 'Launch screenshot.PNG', + mediaType: 'IMAGE/PNG', + sizeBytes: 2048, + status: 'available', + createdAt: '2026-01-02', + }, + { + id: 'notes', + originalName: 'Notes.txt', + mediaType: 'text/plain', + sizeBytes: 9, + status: 'available', + createdAt: '2026-01-03', + }, + ], + }, + }); const rendered = renderRoute( , @@ -551,24 +1017,23 @@ describe('clickable project routes', () => { }); it('renders an idea with no video and exposes the server claim permission', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - id: 'idea', - kind: 'idea', - group: null, - members: [], - media: [], - permissions: { - canEdit: false, - canDelete: false, - canClaim: true, - canManageMedia: false, - }, + mockProjectDetails({ + detail: { + ...projectFixture, + id: 'idea', + kind: 'idea', + group: null, + members: [], + media: [], + permissions: { + canEdit: false, + canDelete: false, + canClaim: true, + canManageMedia: false, + canVote: false, }, - }), - ); + }, + }); renderRoute( , @@ -580,6 +1045,10 @@ describe('clickable project routes', () => { expect( screen.getByRole('link', {name: 'Claim this idea'}).getAttribute('href'), ).toContain('?claim'); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + expect( + fetchMock.mock.calls.some(([input]) => requestUrl(input).includes('/api/votes?')), + ).toBe(false); }); it('uploads media and refreshes project query state', async () => { @@ -591,11 +1060,20 @@ describe('clickable project routes', () => { canDelete: true, canClaim: false, canManageMedia: true, + canVote: false, }, }; fetchMock.mockImplementation(async (input, init) => { - if (init?.method === 'POST') + const url = requestUrl(input); + if (url === '/api/media/projects/project' && init?.method === 'POST') return json({media: {id: 'media', originalName: 'proof.txt'}}, 201); + if (url.includes('/api/votes?')) + return json({ + year: {id: '2026', votingEnabled: false}, + categories: [], + votes: [], + }); + if (url.endsWith('/video')) return json({video: null}); return json({project: detail}); }); @@ -635,6 +1113,80 @@ function json(value: T, status = 200) { }); } +function requestUrl(input: string | URL | Request) { + return input instanceof Request ? input.url : input instanceof URL ? input.href : input; +} + +function mockProjectDetails({ + detail, + ballot = { + year: {id: detail.yearId, votingEnabled: false}, + categories: [], + votes: [], + }, +}: { + detail: ProjectDetail; + ballot?: BallotStatusResponse; +}) { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return json(ballot); + if (url.endsWith('/video')) return json({video: null}); + return json({project: detail}); + }); +} + +function mockProjectsOverview({ + votingEnabled = true, + categories = [], + votes = [], + projects = [], + ballotError = false, +}: { + votingEnabled?: boolean; + categories?: Array<{id: string; yearId: string; name: string}>; + votes?: Array<{ + id: string; + yearId: string; + projectId: string; + projectName: string; + projectActive: boolean; + categoryId: string; + }>; + projects?: ProjectDetail[]; + ballotError?: boolean; +}) { + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled, + submissionsClosed: false, + isCurrent: true, + projectCount: projects.length, + ideaCount: 0, + groupCount: 0, + participantCount: 0, + }, + groups: [], + awards: [], + }); + } + if (url.includes('/api/votes?')) { + if (ballotError) { + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + return json({year: {id: '2026', votingEnabled}, categories, votes}); + } + return json({projects, nextCursor: null}); + }); +} + const projectFixture: ProjectDetail = { id: 'project', yearId: '2026', @@ -667,5 +1219,11 @@ const projectFixture: ProjectDetail = { ], mediaCount: 0, media: [], - permissions: {canEdit: true, canDelete: true, canClaim: false, canManageMedia: true}, + permissions: { + canEdit: true, + canDelete: true, + canClaim: false, + canManageMedia: true, + canVote: false, + }, }; diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 813b40d..479f819 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -74,6 +74,31 @@ describe('project and history APIs', () => { expect(page.body.projects[0].members).toBeInstanceOf(Array); }); + it('exposes project voting permission for eligible viewers but not creators, members, or ideas', async () => { + const project = await createProject(memberToken); + const idea = await createProject(memberToken, {kind: 'idea', groupId: null}); + const creatorView = await api(`/projects/${project.id}`, memberToken); + + await session(outsiderToken); + const outsider = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-outsider-${suffix}`) + .first<{id: string}>(); + const eligibleView = await api(`/projects/${project.id}`, outsiderToken); + const ideaView = await api(`/projects/${idea.id}`, outsiderToken); + + await env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ) + .bind(project.id, outsider!.id) + .run(); + const memberView = await api(`/projects/${project.id}`, outsiderToken); + + expect(creatorView.body.project.permissions.canVote).toBe(false); + expect(eligibleView.body.project.permissions.canVote).toBe(true); + expect(memberView.body.project.permissions.canVote).toBe(false); + expect(ideaView.body.project.permissions.canVote).toBe(false); + }); + it('searches titles and descriptions before pagination with relevant results first', async () => { const exact = await createProject(memberToken, { name: 'Signal', diff --git a/test/voting/voting.test.ts b/test/voting/voting.test.ts index 66af722..4bd23b1 100644 --- a/test/voting/voting.test.ts +++ b/test/voting/voting.test.ts @@ -76,37 +76,89 @@ beforeEach(async () => { }); describe('voting invariants', () => { - it('returns categories, nominated projects, and only the current user votes', async () => { - await env.DB.prepare( - `INSERT INTO project_nominations (project_id, award_category_id, position) - VALUES (?, ?, 1)`, - ) - .bind(projectId, categoryId) - .run(); + it('returns compact current-user ballot status without requiring nominations', async () => { const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); expect(created.status).toBe(201); + await env.DB.prepare('UPDATE projects SET name = ? WHERE id = ?') + .bind('Renamed signal', projectId) + .run(); const voting = await api(`/votes?year=${yearId}`, voterToken); const otherUser = await api(`/votes?year=${yearId}`, memberToken); + const nominations = await env.DB.prepare( + 'SELECT COUNT(*) count FROM project_nominations WHERE project_id = ?', + ) + .bind(projectId) + .first<{count: number}>(); - expect(voting.body).toMatchObject({ + expect(nominations?.count).toBe(0); + expect(voting.body).toEqual({ + year: {id: yearId, votingEnabled: true}, + categories: [{id: categoryId, yearId, name: 'Delight'}], + votes: [ + { + id: created.body.vote.id, + yearId, + projectId, + projectName: 'Renamed signal', + projectActive: true, + categoryId, + }, + ], + }); + expect(otherUser.body).toEqual({ year: {id: yearId, votingEnabled: true}, - categories: [{id: categoryId, name: 'Delight'}], - projects: expect.arrayContaining([ - expect.objectContaining({ - id: projectId, - nominations: [{categoryId, position: 1}], - eligible: true, - }), - ]), - votes: [expect.objectContaining({projectId, categoryId})], + categories: [{id: categoryId, yearId, name: 'Delight'}], + votes: [], }); - expect(otherUser.body.votes).toEqual([]); - expect( - otherUser.body.projects.find( - (project: {id: string}) => project.id === ownProjectId, - ), - ).toMatchObject({eligible: false}); + }); + + it('keeps a withdrawn project selection visible and movable', async () => { + const replacementProjectId = `${projectId}-replacement`; + await env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind( + replacementProjectId, + replacementProjectId, + yearId, + creatorId, + 'Replacement signal', + ) + .run(); + const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); + await env.DB.prepare("UPDATE projects SET status = 'withdrawn' WHERE id = ?") + .bind(projectId) + .run(); + + const withdrawnStatus = await api(`/votes?year=${yearId}`, voterToken); + expect(withdrawnStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId, + projectName: 'Signal', + projectActive: false, + categoryId, + }), + ]); + + const moved = await api(`/votes/${created.body.vote.id}`, voterToken, { + method: 'PUT', + body: {...voteBody(), projectId: replacementProjectId}, + }); + expect(moved.body.vote.projectId).toBe(replacementProjectId); + + const movedStatus = await api(`/votes?year=${yearId}`, voterToken); + expect(movedStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId: replacementProjectId, + projectName: 'Replacement signal', + projectActive: true, + categoryId, + }), + ]); }); it('rejects disabled, self-project, cross-year, and invalid reference votes', async () => { @@ -167,6 +219,7 @@ describe('voting invariants', () => { ), ]); + const status = await api(`/votes?year=${otherYearId}`, voterToken); const cast = await api('/votes', voterToken, { method: 'POST', body: { @@ -187,6 +240,7 @@ describe('voting invariants', () => { method: 'DELETE', }); + expect(status.body.year).toEqual({id: otherYearId, votingEnabled: false}); for (const response of [cast, replaced, deleted]) { expect(response).toMatchObject({ status: 400,