Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/components/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function ProjectCard({
groupName={project.group?.name ?? 'ungrouped'}
members={project.members}
needsHelp={project.needsHelp}
hasVideo={project.hasVideo}
voteCategories={voteCategories}
/>
);
Expand Down Expand Up @@ -62,6 +63,7 @@ export function ProjectListItem({
detail,
members,
needsHelp = false,
hasVideo = false,
emptyMemberLabel = 'up for grabs',
voteCategories = [],
}: {
Expand All @@ -74,6 +76,7 @@ export function ProjectListItem({
detail?: string;
members: ProjectListMember[];
needsHelp?: boolean;
hasVideo?: boolean;
emptyMemberLabel?: string;
voteCategories?: string[];
}) {
Expand All @@ -96,6 +99,7 @@ export function ProjectListItem({
<div className="projectRowTags">
<span className="tag tag--group">{groupName}</span>
{detail && <span className="tag">{detail}</span>}
<ProjectVideoTag hasVideo={hasVideo} />
<ProjectVoteBadge categories={voteCategories} />
{needsHelp && <strong className="tag tag--help">looking for help</strong>}
</div>
Expand Down Expand Up @@ -124,11 +128,16 @@ function ProjectTags({project, className}: {project: ProjectSummary; className:
<span className="tag tag--group">
{project.kind === 'idea' ? 'open idea' : (project.group?.name ?? 'ungrouped')}
</span>
<ProjectVideoTag hasVideo={project.hasVideo} />
{project.needsHelp && <strong className="tag tag--help">looking for help</strong>}
</div>
);
}

function ProjectVideoTag({hasVideo}: {hasVideo: boolean}) {
return hasVideo ? <strong className="tag tag--video">has video</strong> : null;
}

export function MemberStack({
members,
emptyLabel = 'up for grabs',
Expand Down
4 changes: 3 additions & 1 deletion src/app/queries/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function useProjects(
kind?: 'project' | 'idea',
group?: string,
search?: string,
hasVideo?: boolean,
cursor?: string,
) {
const query = new URLSearchParams({
Expand All @@ -48,9 +49,10 @@ export function useProjects(
if (kind) query.set('kind', kind);
if (group) query.set('group', group);
if (search) query.set('q', search);
if (hasVideo) query.set('hasVideo', 'true');
if (cursor) query.set('cursor', cursor);
return useQuery({
queryKey: ['projects', yearId, kind, group, search, cursor ?? null],
queryKey: ['projects', yearId, kind, group, search, hasVideo, cursor ?? null],
queryFn: () => apiRequest<ProjectsResponse>(`/projects?${query}`),
placeholderData: keepPreviousData,
});
Expand Down
18 changes: 17 additions & 1 deletion src/app/routes/ProjectsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
const [searchParams, setSearchParams] = useSearchParams();
const [kind, setKind] = useState<'project' | 'idea'>('project');
const group = searchParams.get('group') ?? '';
const [hasVideoOnly, setHasVideoOnly] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [cursor, setCursor] = useState<string | undefined>();
Expand All @@ -52,6 +53,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
kind,
kind === 'project' ? group || undefined : undefined,
search || undefined,
kind === 'project' && hasVideoOnly ? true : undefined,
cursor,
);
const error = year.error ?? projects.error;
Expand Down Expand Up @@ -215,6 +217,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
className={kind === 'idea' ? 'active' : ''}
onClick={() => {
setKind('idea');
setHasVideoOnly(false);
resetPagination();
}}
>
Expand All @@ -238,6 +241,19 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
</select>
</label>
)}
{kind === 'project' && (
<label className="projectVideoFilter">
<input
type="checkbox"
checked={hasVideoOnly}
onChange={(event) => {
setHasVideoOnly(event.target.checked);
resetPagination();
}}
/>
<span>Has video</span>
</label>
)}
<div className="projectViewToggle" role="group" aria-label="Project view">
{(['grid', 'list'] as const).map((option) => (
<button
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -284,7 +300,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
<span>∅</span>
<h2>No {kind === 'idea' ? 'ideas' : 'projects'} found</h2>
<p>
{search
{search || (kind === 'project' && hasVideoOnly)
? 'try another search or adjust the filters.'
: `try another group or add the first ${kind} for this Hackweek.`}
</p>
Expand Down
17 changes: 17 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,18 @@ main {
background: #fff;
box-shadow: 0 1px 2px rgba(29, 17, 39, 0.12);
}
.projectVideoFilter {
min-height: 2.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid #c9c3d1;
border-radius: 0.5rem;
background: #fff;
cursor: pointer;
}
.projectVideoFilter input {
margin: 0;
accent-color: var(--blurple);
}
.projectControls label,
.operationsBar label {
display: flex;
Expand Down Expand Up @@ -1106,6 +1118,11 @@ main {
border-radius: 999px;
background: #e9d8fd;
}
.tag--video {
color: #305500;
border-color: #b8db78;
background: #f0ffd7;
}
.tag--help,
.detailTags span + span {
color: #6f1648;
Expand Down
1 change: 1 addition & 0 deletions src/shared/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface ProjectSummary {
group: GroupSummary | null;
members: ProjectMember[];
mediaCount: number;
hasVideo: boolean;
}

export interface ProjectDetail extends ProjectSummary {
Expand Down
16 changes: 15 additions & 1 deletion src/worker/repositories/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface ProjectRow {
creator_avatar: string | null;
creator_admin: number;
media_count: number;
has_video: number;
}

interface MemberRow {
Expand Down Expand Up @@ -191,6 +192,7 @@ export async function listProjects(
kind?: 'project' | 'idea';
groupId?: string;
search?: string;
hasVideo?: boolean;
limit: number;
offset: number;
},
Expand All @@ -210,6 +212,10 @@ export async function listProjects(
bindings.push(options.groupId);
countBindings.push(options.groupId);
}
if (options.hasVideo) {
conditions.push(readyVideoExistsSql);
countConditions.push(readyVideoExistsSql);
}
Comment on lines +215 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The 'Has video' filter for projects is incorrectly applied to the count for ideas, causing the idea count to show 0 when the filter is active.
Severity: MEDIUM

Suggested Fix

The readyVideoExistsSql condition should only be applied to the main query's conditions list, not the countConditions list. This will ensure that filtering by video only affects the list of projects returned, while the counts for both projects and ideas in the tabs remain unfiltered by the video criteria, reflecting the total numbers for the selected year.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/worker/repositories/projects.ts#L215-L218

Potential issue: When a user filters projects by the 'Has video' option, the query
incorrectly applies this filter to the count of 'ideas' as well. The
`readyVideoExistsSql` condition is added to `countConditions`, which is used to
calculate counts for both projects and ideas. However, ideas cannot have videos, and the
'Has video' filter is only visible on the projects tab. This causes the UI to display an
incorrect count of 0 for ideas when the video filter is active, leading to user
confusion as they will see 'Ideas (0)' even when ideas are present.

let relevanceOrder = '';
if (options.search) {
const escapedSearch = escapeLikePattern(options.search);
Expand Down Expand Up @@ -665,11 +671,18 @@ function projectSelect() {
g.id group_id, g.name group_name,
u.email creator_email, u.display_name creator_name,
u.avatar_url creator_avatar, u.is_admin creator_admin,
(SELECT COUNT(*) FROM media m WHERE m.project_id = p.id AND m.status = 'available') media_count
(SELECT COUNT(*) FROM media m WHERE m.project_id = p.id AND m.status = 'available') media_count,
${readyVideoExistsSql} has_video
FROM projects p JOIN users u ON u.id = p.creator_id
LEFT JOIN groups g ON g.id = p.group_id`;
}

const readyVideoExistsSql = `EXISTS (
SELECT 1 FROM video_submissions video
WHERE video.project_id = p.id AND video.status = 'ready'
AND video.retired_at IS NULL AND video.processed_r2_key IS NOT NULL
)`;

function mapProject(row: ProjectRow, members: ProjectMember[]): ProjectSummary {
return {
id: row.id,
Expand All @@ -696,6 +709,7 @@ function mapProject(row: ProjectRow, members: ProjectMember[]): ProjectSummary {
: null,
members,
mediaCount: row.media_count,
hasVideo: Boolean(row.has_video),
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/worker/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ projectsRoutes.get('/', async (c) => {
const limit = boundedInteger(c.req.query('limit'), 24, 1, 250, 'Limit');
const offset = boundedInteger(c.req.query('cursor'), 0, 0, 100_000, 'Cursor');
const search = boundedSearch(c.req.query('q'));
const hasVideoQuery = c.req.query('hasVideo');
if (hasVideoQuery !== undefined && hasVideoQuery !== 'true') {
throw new ServiceError('VALIDATION_FAILED', 'Has video query is invalid', 400);
}
const response: ProjectsResponse = await listProjects(c.env.DB, {
yearId,
kind,
groupId: c.req.query('group'),
search,
hasVideo: hasVideoQuery === 'true',
limit,
offset,
});
Expand Down
1 change: 1 addition & 0 deletions test/app/ProjectForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ const projectFixture: ProjectDetail = {
group: {id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1},
members: [alice],
mediaCount: 0,
hasVideo: false,
media: [],
nominationCategoryIds: [],
permissions: {
Expand Down
76 changes: 76 additions & 0 deletions test/app/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,81 @@ describe('clickable project routes', () => {
expect(screen.queryByRole('region', {name: 'your projects'})).toBeNull();
});

it('marks playable videos in both views and filters projects by video', async () => {
const readyProject = {...projectFixture, hasVideo: true};
const projectWithoutVideo = {
...projectFixture,
id: 'project-without-video',
name: 'Still recording',
hasVideo: false,
};
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: false,
submissionsClosed: false,
projectCount: 2,
ideaCount: 0,
groupCount: 1,
participantCount: 1,
},
groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 2}],
awards: [],
myProjects: [],
});
}
const hasVideo = new URL(url, 'https://hackweek.test').searchParams.get('hasVideo');
return json({
projects:
hasVideo === 'true' ? [readyProject] : [readyProject, projectWithoutVideo],
nextCursor: null,
});
});

renderRoute(<ProjectsPage />, '/years/2026/projects', '/years/:yearId/projects');

const gridTag = await screen.findByText('has video');
expect(gridTag.closest('.projectCard')).toBeTruthy();
expect(screen.getByRole('heading', {name: 'Still recording'})).toBeTruthy();
expect(screen.getAllByText('has video')).toHaveLength(1);

await userEvent.click(screen.getByRole('button', {name: 'list view'}));
const listTag = screen.getByText('has video');
expect(listTag.closest('.projectRow')).toBeTruthy();

await userEvent.click(screen.getByRole('checkbox', {name: 'Has video'}));

await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('hasVideo=true'),
undefined,
),
);
await waitFor(() =>
expect(screen.queryByRole('heading', {name: 'Still recording'})).toBeNull(),
);
expect(screen.getByRole('heading', {name: 'A small machine'})).toBeTruthy();

await userEvent.click(screen.getByRole('button', {name: /Ideas/}));
expect(screen.queryByRole('checkbox', {name: 'Has video'})).toBeNull();

await userEvent.click(screen.getByRole('button', {name: /Projects/}));
const resetFilter = screen.getByRole('checkbox', {name: 'Has video'});
expect(resetFilter).toBeInstanceOf(HTMLInputElement);
if (!(resetFilter instanceof HTMLInputElement)) throw new Error();
expect(resetFilter.checked).toBe(false);
await waitFor(() => {
const projectRequest = fetchMock.mock.calls
.map(([input]) => requestUrl(input))
.filter((url) => url.includes('/api/projects?'))
.at(-1);
expect(projectRequest).not.toContain('hasVideo=');
});
});

it('keeps closed-year browsing and ballot read failures local', async () => {
mockProjectsOverview({votingEnabled: false, projects: [projectFixture]});
const closed = renderRoute(
Expand Down Expand Up @@ -1859,6 +1934,7 @@ const projectFixture: ProjectDetail = {
},
],
mediaCount: 0,
hasVideo: false,
media: [],
nominationCategoryIds: [],
permissions: {
Expand Down
Loading
Loading