Skip to content
Open
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
26 changes: 26 additions & 0 deletions .forge/features/printing-submission-hours/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Printing Submission Hours

## Goal

Admins can control whether hackers may submit new 3D printing requests for a hackathon, because the printing team is only available on-site during limited hours.

## Users

- Hackers on the yearly portal printing dashboard.
- Admins with Printing Queue access in Blade.

## User-visible behavior

- Hackers can always view their existing print jobs and the queue summary after check-in.
- Hackers can only upload/submit a new print job while printing submissions are currently open.
- When submissions are closed, the dashboard explains that printing is closed and shows the configured window when one exists.
- Admins can set printing submissions open/closed for the selected hackathon.
- Admins can optionally set an open and/or close time, making the control flexible for same-day on-site blocks or manual open-ended sessions.

## Acceptance criteria

- A missing printing configuration is safe-closed for new submissions.
- Admins can save open/closed state and optional open/close times from the Printing settings dialog.
- Hacker submit and upload API paths reject new order creation while submissions are closed.
- The hacker dashboard disables the New print action while submissions are closed.
- Existing print queue/status/cancel flows keep working while submissions are closed.
37 changes: 37 additions & 0 deletions .forge/features/printing-submission-hours/srd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Printing Submission Hours SRD

## Scope

Blade printing configuration gains submission availability fields for one hackathon. The 2026 hacker portal consumes the queue DTO to render open/closed state and prevent new submissions.

## Data model

Extend `PrintingConfiguration` with:

- `submissionsEnabled boolean not null default false`
- `submissionsOpenAt timestamptz null`
- `submissionsCloseAt timestamptz null`

A check constraint requires `submissionsOpenAt < submissionsCloseAt` when both are present.

## Open-state rule

`isOpen = submissionsEnabled && (openAt == null || openAt <= now) && (closeAt == null || closeAt > now)`.

Missing configuration uses defaults: disabled, no window, existing print estimate defaults.

## API contracts

- Admin tRPC `printing.getConfiguration` returns submission state.
- Admin tRPC `printing.setSubmissionWindow` saves the enabled flag and nullable open/close timestamps.
- Hacker `listPrintJobs` includes `queue.submissions` with enabled/open/close/isOpen timestamps.
- Hacker `uploadPrintFile` and `submitPrintJob` reject with `PRINTING_CLOSED` when `isOpen` is false.

## UI contracts

- Blade admin settings dialog adds a Printing availability section.
- Hacker printing dashboard shows an open/closed notice and disables New print when closed.

## Rollout

Migration defaults closed for safety. Admins must explicitly open printing for a hackathon.
18 changes: 18 additions & 0 deletions .forge/features/printing-submission-hours/status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Printing Submission Hours Status

## Phase

Implementation complete; ready for review.

## Decisions

- Missing config defaults to closed for new submissions.
- Admins get a flexible enabled toggle plus optional open/close timestamps.
- Existing jobs remain visible/manageable while new uploads/submits are closed.

## Validation

- Passed: `pnpm format`.
- Passed: `pnpm lint` (repo has existing warnings only).
- Passed: `pnpm typecheck`.
- Passed command / skipped by environment: `pnpm --filter=@forge/api test -- src/tests/integration/printing.test.ts` (20 database integration tests skipped because the local test environment did not enable the disposable database suite).
9 changes: 9 additions & 0 deletions .forge/features/printing-submission-hours/test-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Printing Submission Hours Test Cases

- TC-001: Missing printing configuration returns submissions disabled and closed while preserving default queue estimates.
- TC-002: Admin with Printing Queue permission can save submissions enabled with an optional open/close window; configuration reads back the exact state.
- TC-003: Checked-in hacker can submit and upload while submissions are enabled and the current time is within the optional window.
- TC-004: Checked-in hacker cannot upload or submit while submissions are disabled.
- TC-005: Checked-in hacker cannot upload or submit before a future open time or after a close time.
- TC-006: Existing job listing and cancellation still work while submissions are closed.
- TC-007: Hacker portal disables the New print action and explains closed/open-window state when queue submissions are closed.
72 changes: 69 additions & 3 deletions apps/2026/src/app/(portal)/_components/hacker-printing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,59 @@ export function HackerPrinting() {
return <PrintingWorkshop timeZone={dashboard.hackathon.timezone} />;
}

function formatSubmissionTime(value: string, timeZone: string) {
return new Intl.DateTimeFormat("en-US", {
day: "numeric",
hour: "numeric",
minute: "2-digit",
month: "short",
timeZone,
}).format(new Date(value));
}

function PrintingClosedCopy({
submissions,
timeZone,
}: {
submissions:
| {
closeAt: string | null;
enabled: boolean;
isOpen: boolean;
openAt: string | null;
}
| undefined;
timeZone: string;
}) {
if (!submissions) return <>Checking whether printing is on-site…</>;
if (!submissions.enabled) {
return (
<>
Printing is closed right now. Organizers will reopen submissions when
the printers are on-site.
</>
);
}
if (submissions.openAt) {
return (
<>Printing opens {formatSubmissionTime(submissions.openAt, timeZone)}.</>
);
}
if (submissions.closeAt) {
return (
<>
Printing closed {formatSubmissionTime(submissions.closeAt, timeZone)}.
</>
);
}
return (
<>
Printing is closed right now. Check back when organizers reopen
submissions.
</>
);
}

function PrintingWorkshop({ timeZone }: { timeZone: string }) {
const flow = useHackerPrintingFlow();
const queue = flow.jobsQuery.data?.queue;
Expand All @@ -104,7 +157,11 @@ function PrintingWorkshop({ timeZone }: { timeZone: string }) {
<h1 id="khix-printing-title">3D Printing</h1>
<p>Send your model, track its progress and collect your print.</p>
</div>
<NewPrintJobForm flow={flow} onSubmitted={() => setView("active")} />
<NewPrintJobForm
disabled={!queue?.submissions.isOpen}
flow={flow}
onSubmitted={() => setView("active")}
/>
</header>

<aside aria-label="Our printing sponsor" className={styles.sponsor}>
Expand Down Expand Up @@ -158,8 +215,17 @@ function PrintingWorkshop({ timeZone }: { timeZone: string }) {
)}
</p>
<p className={styles.hint}>
Each print takes {formatPrintTime(queue?.printMinutes ?? 60)}. First
come, first served; ready times may change.
{queue?.submissions.isOpen ? (
<>
Each print takes {formatPrintTime(queue.printMinutes)}. First
come, first served; ready times may change.
</>
) : (
<PrintingClosedCopy
submissions={queue?.submissions}
timeZone={timeZone}
/>
)}
</p>
</div>
</aside>
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 @@ -37,9 +37,11 @@ function errorMessage(error: unknown, fallback: string) {
}

export function NewPrintJobForm({
disabled,
flow,
onSubmitted,
}: {
disabled: boolean;
flow: ReturnType<typeof useHackerPrintingFlow>;
onSubmitted: () => void;
}) {
Expand All @@ -56,6 +58,7 @@ export function NewPrintJobForm({
// file then would race the submit that already holds the list.
const submitting = flow.submitMutation.isPending;
const canSubmit =
!disabled &&
description.trim() !== "" &&
uploadedIds.length > 0 &&
!uploading &&
Expand All @@ -69,6 +72,10 @@ export function NewPrintJobForm({
}

function addFiles(list: FileList) {
if (disabled) {
toast.error("Printing is not accepting new requests right now.");
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 @@ -106,6 +113,10 @@ export function NewPrintJobForm({

async function submit() {
try {
if (disabled) {
toast.error("Printing is not accepting new requests right now.");
return;
}
const job = await flow.submit({
description: description.trim(),
fileIds: uploadedIds,
Expand All @@ -132,7 +143,7 @@ export function NewPrintJobForm({
}}
>
<DialogTrigger asChild>
<Button className={styles.primaryButton}>
<Button className={styles.primaryButton} disabled={disabled}>
<Plus aria-hidden="true" size={18} />
New print
</Button>
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 || disabled}
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 || disabled}
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 @@ -147,6 +147,18 @@ export function PrintingQueueWorkspace({
{queue.settings.printerCount}{" "}
{queue.settings.printerCount === 1 ? "printer" : "printers"}
</span>
<span
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium",
configuration.submissions.isOpen
? "border-[hsl(var(--chart-2)/0.35)] bg-[hsl(var(--chart-2)/0.08)] text-[hsl(var(--chart-2))]"
: "border-destructive/40 bg-destructive/10 text-destructive dark:text-red-300",
)}
>
{configuration.submissions.isOpen
? "Submissions open"
: "Submissions closed"}
</span>
</div>
<div className="relative w-full lg:w-72">
<Search
Expand Down
Loading
Loading