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
2 changes: 2 additions & 0 deletions .forge/features/3d-printing-queue/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,5 @@ Database integration: 17 skipped, including quota/concurrency and organizer-coun
Current branch: `blade/3d-printing-queue`, aligned with the rebased remote head `6502bc3e`. CI run 37251475322 passed all 1,048 API assertions but failed on an unhandled PostgreSQL `57P01` during printing test teardown. The installed `pg-pool` removes idle clients from its internal list before their socket-close callbacks finish, so awaiting `Pool.end()` alone can let disposable-database termination race those connections.

Added a printing-test shutdown tracker before the first database query. Teardown now awaits both pool shutdown and every observed client’s `end` event, and propagates shutdown errors instead of suppressing them. Scope is API test support and printing tests; production database behavior is unchanged. A deterministic no-server regression failed under the previous `Pool.end()`-only behavior before implementing the fix. Validation: root `pnpm format`, `pnpm lint`, and `pnpm typecheck` pass (existing warnings only). Focused printing tests: 12 passed, 17 database tests skipped. Full API suite: 792 passed, 259 database-dependent tests skipped because no local PostgreSQL server is configured; no unhandled errors. The teardown regression was first observed failing with the original behavior and then passing with the fix. PostgreSQL-backed CI confirmation follows the push.

- 2026-10-10 emergency update: user requested a manual open/closed switch only, bundled with the check-in picker fix. Blade queue controls availability per hackathon; hackers see the status and cannot upload or submit while closed. Existing jobs, cancellation, and successful idempotent replays remain available. Migration 0062 defaults printing to closed; an organizer must open it after deployment. Database-backed printing, schema, audit, SDK, validators, and portal tests pass.
5 changes: 5 additions & 0 deletions .forge/features/hackathon-events/srd.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,11 @@ Would this require a developer change next year?

## React / frontend constraints

- Hack Event Check-in passes its event count as `ResponsiveComboBox.maxResults`
so every loaded event remains reachable by scrolling or searching. The
optional prop defaults to 20 for existing consumers; popover and drawer
retain their bounded scroll areas. No API or persisted-data changes are needed.

- Add separate server-first pages `/admin/hackathon-events` and
`/admin/hackathon-check-in`, each with independent auth, permission redirects,
loading/error boundaries, metadata, and client roots.
Expand Down
11 changes: 10 additions & 1 deletion .forge/features/hackathon-events/status.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# Hackathon Events Status

Current phase: Complete / owner-approved for Reforge main
Current phase: Check-in event picker regression fixed and validated

> This file is the maintained progress tracker for the feature/change. Keep it current whenever decisions, tasks, validation, or open questions change.

## Decision log

- 2026-10-10: The reported missing event is available through check-in search.
`listCheckInEvents` returns all events, but `ResponsiveComboBox` truncates
both browsing and search to 20 choices. Two regression cases with 62 events
fail when selecting the last workshop, with and without a search query.
Add an optional result limit to the shared picker and let Hack Event Check-in
display its complete event list. Preserve the default for other consumers.
Validation: 11 check-in unit tests and desktop/mobile scroll and keyboard
browser checks pass. Shared UI build, React analysis, format, lint, and types pass.

- 2026-08-05: Created `reforge/hackathon-events` from Reforge main commit
`976fa0b1d`, after hacker management landed.
- 2026-08-05: Historical Spec Miner method used for evidence collection only.
Expand Down
9 changes: 9 additions & 0 deletions .forge/features/hackathon-events/test-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,15 @@ Expected observations:
- No hack event appears or mutates through Club code.
- Existing Club Event tests and reminder cadence remain unchanged.

### TC-REG-002: Browse the complete check-in event picker

- Load at least 60 events for one hackathon and open the check-in event picker.
- Without entering a search, scroll to and select the final event. Verify the
selected event and URL identify that event.
- Search for a term matching more than 20 events and select the final match.
- Verify desktop popover and mobile drawer scrolling, plus keyboard selection.
- Existing combo-box consumers retain their default result limit.

## Open questions

- None. Adversarial review findings are incorporated before this document is
Expand Down
27 changes: 25 additions & 2 deletions apps/2026/src/app/(portal)/_components/hacker-printing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,38 @@ function PrintingWorkshop({ timeZone }: { timeZone: string }) {
<aside aria-label="How printing works" className={styles.notice}>
<Clock3 aria-hidden="true" className={styles.noticeIcon} />
<div>
<p role="status">
<strong>
{flow.jobsQuery.isError
? "Printing availability could not be loaded."
: queue
? queue.isOpen
? "Printing is open"
: "Printing is currently closed"
: "Checking printing availability…"}
</strong>
</p>
{queue && !queue.isOpen ? (
<p className={styles.hint}>
New requests are paused until the printer is on site. You can
still track your existing prints here.
</p>
) : null}
<p>
{queue ? (
<>
<strong>
{queue.waitingCount}{" "}
{queue.waitingCount === 1 ? "job" : "jobs"} in the queue
</strong>
<span className={styles.noticeDivider}>·</span>A new print:{" "}
<strong>{formatDuration(queue.estimatedWaitMinutes)}</strong>
{queue.isOpen ? (
<>
<span className={styles.noticeDivider}>·</span>A new print:{" "}
<strong>
{formatDuration(queue.estimatedWaitMinutes)}
</strong>
</>
) : null}
</>
) : (
"Checking the print queue…"
Expand Down
20 changes: 17 additions & 3 deletions apps/2026/src/app/(portal)/_components/new-print-job-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export function NewPrintJobForm({
// While a job is being sent the file list is frozen: adding or removing a
// file then would race the submit that already holds the list.
const submitting = flow.submitMutation.isPending;
const isOpen =
flow.jobsQuery.data?.queue.isOpen === true && !flow.jobsQuery.isError;
const canSubmit =
isOpen &&
description.trim() !== "" &&
uploadedIds.length > 0 &&
!uploading &&
Expand All @@ -69,6 +72,7 @@ export function NewPrintJobForm({
}

function addFiles(list: FileList) {
if (!isOpen || submitting) return;
const room = PRINTING.MAX_PRINT_JOB_FILES - files.length;
const picked = Array.from(list).slice(0, Math.max(room, 0));
if (list.length > picked.length) {
Expand Down Expand Up @@ -105,6 +109,7 @@ export function NewPrintJobForm({
}

async function submit() {
if (!canSubmit) return;
try {
const job = await flow.submit({
description: description.trim(),
Expand Down Expand Up @@ -132,7 +137,7 @@ export function NewPrintJobForm({
}}
>
<DialogTrigger asChild>
<Button className={styles.primaryButton}>
<Button className={styles.primaryButton} disabled={!isOpen}>
<Plus aria-hidden="true" size={18} />
New print
</Button>
Expand All @@ -156,6 +161,12 @@ export function NewPrintJobForm({
void submit();
}}
>
{!isOpen ? (
<p className={styles.fileWarning} role="alert">
Printing is currently unavailable. You can send this request when
printing reopens.
</p>
) : null}
<p className={styles.formStep}>
01 <span>Tell us about your print</span>
</p>
Expand Down Expand Up @@ -196,13 +207,16 @@ export function NewPrintJobForm({
</p>
) : null}
{files.length < PRINTING.MAX_PRINT_JOB_FILES ? (
<label aria-disabled={submitting} className={styles.picker}>
<label
aria-disabled={submitting || !isOpen}
className={styles.picker}
>
<FileUp aria-hidden="true" className="size-4" />
Add files
<input
accept={uploadAccept(PRINT_FILE_UPLOAD_POLICY)}
className="sr-only"
disabled={submitting}
disabled={submitting || !isOpen}
multiple
onChange={(event) => {
if (event.target.files) addFiles(event.target.files);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export function HackathonCheckInWorkspace() {
selection.hackathonId !== selectedHackathon?.id
}
items={eventData.data?.events ?? []}
maxResults={eventData.data?.events.length}
onValueChange={(eventId) => {
if (selection.hackathonId)
replaceSelection(selection.hackathonId, eventId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,24 @@ import type { RouterOutputs } from "@forge/api";
import { PRINTING } from "@forge/consts";
import { cn } from "@forge/ui";
import { Input } from "@forge/ui/input";
import { Label } from "@forge/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@forge/ui/select";
import { Switch } from "@forge/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@forge/ui/tabs";
import { toast } from "@forge/ui/toast";

import {
AdminPageHeader,
adminPageLayoutClassName,
} from "~/app/_components/shared/admin-page";
import { useNavigationRouter as useRouter } from "~/app/_components/shared/route-transition-link";
import { api } from "~/trpc/react";
import { PrintJobDialog } from "./print-job-dialog";
import { formatPrintDateTime, formatPrintTime } from "./print-queue-format";
import { PrintStatusPill } from "./print-status-pill";
Expand Down Expand Up @@ -119,6 +123,10 @@ export function PrintingQueueWorkspace({
configuration={configuration}
hackathonId={selected.id}
/>
<PrintingAvailabilitySwitch
hackathonId={selected.id}
isOpen={configuration.isOpen}
/>
</>
}
description="Review incoming models, update progress and keep pickup moving."
Expand Down Expand Up @@ -249,6 +257,43 @@ export function PrintingQueueWorkspace({
);
}

function PrintingAvailabilitySwitch({
hackathonId,
isOpen,
}: {
hackathonId: string;
isOpen: boolean;
}) {
const router = useRouter();
const [refreshing, startTransition] = useTransition();
const availability = api.printing.setAvailability.useMutation();
return (
<Label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md border border-border px-3">
<Switch
aria-label="Accept new print jobs"
checked={isOpen}
disabled={availability.isPending || refreshing}
onCheckedChange={async (next) => {
try {
await availability.mutateAsync({ hackathonId, isOpen: next });
toast.success(
next ? "Printing is open." : "Printing is closed to new jobs.",
);
startTransition(() => router.refresh());
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Printing availability could not be saved.",
);
}
}}
/>
Printing {isOpen ? "open" : "closed"}
</Label>
);
}

function PrintQueueRow({
job,
timezone,
Expand Down
62 changes: 62 additions & 0 deletions apps/blade/src/tests/e2e/hackathon-events.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "playwright/test";

import { PERMISSIONS } from "@forge/consts";
Expand Down Expand Up @@ -178,6 +179,67 @@ test.describe("Hackathon event and check-in critical flow", () => {

test.afterAll(cleanupFixtures);

for (const width of [1440, 320]) {
test(`browses all check-in events at ${width}px`, async ({
page,
}, testInfo) => {
const events = Array.from({ length: 60 }, (_, index) => ({
description: "Check-in picker regression fixture",
discordSyncState: "disabled" as const,
end_datetime: new Date("2027-08-06T21:00:00.000Z"),
googleSyncState: "disabled" as const,
hackathonId: HACKATHON_ID,
id: randomUUID(),
location: "Workshop room",
name: `Workshop ${String(index + 1).padStart(2, "0")}`,
points: 10,
start_datetime: new Date(Date.UTC(2027, 7, 6, 12, index)),
tag: "Workshop",
}));
const finalEvent = events.at(-1);
if (!finalEvent) throw new Error("Missing event fixture");
finalEvent.name = "Workshop 60 · IEEE: Embedded Systems II";
await db.insert(Event).values(events);
try {
await page.setViewportSize({ height: 900, width });
await page.goto(
`/api/e2e/signin?userId=${ADMIN_ID}&callbackURL=${encodeURIComponent(`/admin/hackathon-check-in?hackathon=${HACKATHON_ID}`)}`,
);
const trigger = page.getByRole("combobox", {
name: "Event",
exact: true,
});
await trigger.click();
const list = page.getByRole("listbox");
const last = page.getByRole("option", { name: /Workshop 60/ });
await list.hover();
await page.mouse.wheel(0, 10_000);
await expect(last).toBeInViewport();
await page.screenshot({
path: testInfo.outputPath("event-picker.png"),
});
await last.click();
await expect(trigger).toContainText(finalEvent.name);
await expect(page).toHaveURL(new RegExp(`event=${finalEvent.id}`));

await trigger.click();
const search = page.getByPlaceholder("Search events");
await search.fill("Workshop");
await search.press("End");
await expect(last).toHaveAttribute("aria-selected", "true");
await search.press("Enter");
await expect(trigger).toContainText(finalEvent.name);
} finally {
await db.delete(Event).where(
inArray(
Event.id,
events.map(({ id }) => id),
),
);
}
});
}

test("keeps linked hack tag identity after rename and an unrelated event edit", async ({
page,
}) => {
Expand Down
Loading
Loading