@commonpub/layer 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,6 +91,7 @@ function confirmWithdraw(entryId: string): void {
91
91
  v-for="(entry, i) in entries"
92
92
  :key="entry.id"
93
93
  class="cpub-entry-card"
94
+ :class="{ 'cpub-entry-out': entry.eliminated }"
94
95
  >
95
96
  <div class="cpub-entry-thumb" :class="i % 2 === 0 ? 'cpub-entry-bg-light' : 'cpub-entry-bg-dark'">
96
97
  <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" class="cpub-entry-cover-img" />
@@ -99,6 +100,8 @@ function confirmWithdraw(entryId: string): void {
99
100
  <div class="cpub-entry-icon"><i class="fa-solid fa-microchip"></i></div>
100
101
  </template>
101
102
  <span v-if="entry.rank" class="cpub-entry-rank" :class="`cpub-rank-${entry.rank <= 3 ? entry.rank : 'other'}`">#{{ entry.rank }}</span>
103
+ <span v-if="entry.eliminated" class="cpub-entry-cohort cpub-cohort-out"><i class="fa-solid fa-circle-minus"></i> Not advanced</span>
104
+ <span v-else-if="entry.stageState && entry.stageState.some((s) => s.status === 'advanced')" class="cpub-entry-cohort cpub-cohort-in"><i class="fa-solid fa-circle-check"></i> Advanced</span>
102
105
  </div>
103
106
  <div class="cpub-entry-body">
104
107
  <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
@@ -165,6 +168,12 @@ function confirmWithdraw(entryId: string): void {
165
168
  .cpub-rank-2 { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--text-faint); }
166
169
  .cpub-rank-3 { background: var(--surface2); color: var(--bronze); border: var(--border-width-default) solid var(--bronze); }
167
170
  .cpub-rank-other { background: var(--surface2); color: var(--text-dim); border: var(--border-width-default) solid var(--border); }
171
+ .cpub-entry-cohort { position: absolute; top: 8px; right: 8px; z-index: 2; font-size: 9px; font-family: var(--font-mono); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 4px; }
172
+ .cpub-cohort-in { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green-border); }
173
+ .cpub-cohort-out { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--border2); }
174
+ .cpub-entry-cohort i { font-size: 8px; }
175
+ .cpub-entry-out { opacity: .6; }
176
+ .cpub-entry-out:hover { opacity: 1; }
168
177
  .cpub-entry-body { padding: 10px 12px; }
169
178
  .cpub-entry-title { font-size: 12px; font-weight: 600; margin-bottom: 3px; line-height: 1.3; color: var(--text); text-decoration: none; }
170
179
  .cpub-entry-title:hover { color: var(--accent); }
@@ -20,11 +20,6 @@ const props = defineProps<{
20
20
 
21
21
  const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
22
22
 
23
- function newId(): string {
24
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
25
- return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
26
- }
27
-
28
23
  // datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
29
24
  function toLocal(iso?: string): string {
30
25
  if (!iso) return '';
@@ -50,39 +45,28 @@ function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
50
45
  setField(i, { [field]: v } as Partial<ContestStage>);
51
46
  }
52
47
 
48
+ // Array operations live as pure functions in utils/contestStages.ts (unit-tested).
53
49
  function addStage(): void {
54
- commit([...stages.value, { id: newId(), name: '', kind: 'custom' }]);
50
+ commit(withStageAdded(stages.value));
55
51
  }
56
52
 
57
53
  function duplicateStage(i: number): void {
58
- const src = stages.value[i];
59
- if (!src) return;
60
- const copy: ContestStage = { ...src, id: newId(), name: `${src.name} (copy)`, core: false };
61
- commit([...stages.value.slice(0, i + 1), copy, ...stages.value.slice(i + 1)]);
54
+ commit(withStageDuplicated(stages.value, i));
62
55
  }
63
56
 
64
57
  function removeStage(i: number): void {
65
58
  const removed = stages.value[i];
66
- commit(stages.value.filter((_, idx) => idx !== i));
59
+ commit(withStageRemoved(stages.value, i));
67
60
  if (removed && currentId.value === removed.id) currentId.value = null;
68
61
  }
69
62
 
70
63
  function move(i: number, dir: -1 | 1): void {
71
- const j = i + dir;
72
- if (j < 0 || j >= stages.value.length) return;
73
- const next = [...stages.value];
74
- [next[i], next[j]] = [next[j]!, next[i]!];
75
- commit(next);
64
+ commit(withStageMoved(stages.value, i, dir));
76
65
  }
77
66
 
78
67
  // Seed the editor with the standard three stages so the owner has a starting point.
79
68
  function customize(): void {
80
- const iso = (d?: string | null) => (d ? new Date(d).toISOString() : undefined);
81
- commit([
82
- { id: newId(), name: 'Submissions', kind: 'submission', startsAt: iso(props.startDate), endsAt: iso(props.endDate) },
83
- { id: newId(), name: 'Judging', kind: 'review', endsAt: iso(props.judgingEndDate) ?? iso(props.endDate) },
84
- { id: newId(), name: 'Results', kind: 'results' },
85
- ]);
69
+ commit(seedStandardStages(props));
86
70
  }
87
71
 
88
72
  function resetToStandard(): void {
@@ -108,6 +92,13 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
108
92
  </div>
109
93
 
110
94
  <template v-else>
95
+ <div class="cpub-stage-tophead">
96
+ <span class="cpub-stage-count">{{ stages.length }} stage{{ stages.length === 1 ? '' : 's' }}</span>
97
+ <div class="cpub-stage-toolbar">
98
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
99
+ <button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard</button>
100
+ </div>
101
+ </div>
111
102
  <p v-if="missingSubmission" class="cpub-form-error" role="alert" style="margin: 0 0 10px;">
112
103
  Add at least one <strong>Submissions</strong> stage, or reset to the standard flow.
113
104
  </p>
@@ -193,7 +184,6 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
193
184
 
194
185
  <div class="cpub-stage-toolbar">
195
186
  <button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
196
- <button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard flow</button>
197
187
  </div>
198
188
  </template>
199
189
  </div>
@@ -201,6 +191,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
201
191
 
202
192
  <style scoped>
203
193
  .cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
194
+ .cpub-stage-tophead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
195
+ .cpub-stage-count { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }
204
196
  .cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
205
197
  .cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
206
198
  .cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,16 +53,16 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/editor": "0.7.11",
57
56
  "@commonpub/auth": "0.8.0",
58
57
  "@commonpub/config": "0.18.0",
58
+ "@commonpub/editor": "0.7.11",
59
59
  "@commonpub/docs": "0.6.3",
60
60
  "@commonpub/protocol": "0.13.0",
61
- "@commonpub/schema": "0.29.0",
62
- "@commonpub/server": "2.76.0",
63
- "@commonpub/ui": "0.9.2",
61
+ "@commonpub/explainer": "0.7.15",
64
62
  "@commonpub/learning": "0.5.2",
65
- "@commonpub/explainer": "0.7.15"
63
+ "@commonpub/server": "2.77.0",
64
+ "@commonpub/ui": "0.9.2",
65
+ "@commonpub/schema": "0.30.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -216,6 +216,30 @@ async function handleDelete(): Promise<void> {
216
216
  const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
217
217
  const statusAction = contestStatusAction;
218
218
 
219
+ // Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
220
+ // not the editable `stages` ref, since it acts on real entries.
221
+ const advancing = ref<string | null>(null);
222
+ const advanceN = ref<Record<string, number>>({});
223
+ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
224
+ async function advanceStage(stageId: string): Promise<void> {
225
+ const topN = advanceN.value[stageId];
226
+ if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
227
+ if (!confirm(`Advance the top ${topN} entries from this stage? Entries below the cut are marked "not advanced" and drop out of later judging + final results. You can re-run this.`)) return;
228
+ advancing.value = stageId;
229
+ try {
230
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
231
+ method: 'POST',
232
+ body: { reviewStageId: stageId, mode: 'topN', topN },
233
+ });
234
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
235
+ await refresh();
236
+ } catch (err: unknown) {
237
+ toast.error(extractError(err));
238
+ } finally {
239
+ advancing.value = null;
240
+ }
241
+ }
242
+
219
243
  async function transitionStatus(newStatus: string): Promise<void> {
220
244
  // Only the consequential transitions confirm; reversible nudges (pause/resume,
221
245
  // go-back) just apply.
@@ -244,6 +268,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
244
268
  </p>
245
269
 
246
270
  <form class="cpub-edit-form" @submit.prevent="handleSave">
271
+ <div class="cpub-edit-layout">
272
+ <div class="cpub-edit-main">
247
273
  <section class="cpub-form-section">
248
274
  <h2 class="cpub-form-section-title">Details</h2>
249
275
  <div class="cpub-form-field">
@@ -309,22 +335,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
309
335
  />
310
336
  </section>
311
337
 
312
- <section class="cpub-form-section">
313
- <h2 class="cpub-form-section-title">Entries</h2>
314
- <div class="cpub-form-field">
315
- <span class="cpub-form-label">Eligible content types</span>
316
- <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
317
- <div class="cpub-type-options" role="group" aria-label="Eligible content types">
318
- <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
319
- <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
320
- <span>{{ t.label }}</span>
321
- </label>
338
+ <section v-if="reviewStages.length" class="cpub-form-section">
339
+ <h2 class="cpub-form-section-title">Advancement</h2>
340
+ <p class="cpub-form-hint">Multi-round contests: after judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced" and excluded from later judging + final results. Re-running re-computes the cut. (Save any stage changes above first.)</p>
341
+ <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-row">
342
+ <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
343
+ <div class="cpub-advance-ctl">
344
+ <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
345
+ <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
346
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
347
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
348
+ </button>
322
349
  </div>
323
350
  </div>
324
- <div class="cpub-form-field">
325
- <label class="cpub-form-label">Max entries per person</label>
326
- <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
327
- </div>
328
351
  </section>
329
352
 
330
353
  <section class="cpub-form-section">
@@ -443,6 +466,26 @@ async function transitionStatus(newStatus: string): Promise<void> {
443
466
  <p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
444
467
  <ContestJudgeManager :contest-slug="slug" :is-owner="true" />
445
468
  </section>
469
+ </div><!-- /cpub-edit-main -->
470
+
471
+ <aside class="cpub-edit-side">
472
+ <section class="cpub-form-section">
473
+ <h2 class="cpub-form-section-title">Entries</h2>
474
+ <div class="cpub-form-field">
475
+ <span class="cpub-form-label">Eligible content types</span>
476
+ <p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
477
+ <div class="cpub-type-options" role="group" aria-label="Eligible content types">
478
+ <label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
479
+ <input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
480
+ <span>{{ t.label }}</span>
481
+ </label>
482
+ </div>
483
+ </div>
484
+ <div class="cpub-form-field">
485
+ <label class="cpub-form-label">Max entries per person</label>
486
+ <input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
487
+ </div>
488
+ </section>
446
489
 
447
490
  <section class="cpub-form-section">
448
491
  <h2 class="cpub-form-section-title">Stage &amp; Status</h2>
@@ -487,6 +530,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
487
530
  </button>
488
531
  </div>
489
532
  </section>
533
+ </aside><!-- /cpub-edit-side -->
534
+ </div><!-- /cpub-edit-layout -->
490
535
 
491
536
  <!-- Sticky save bar — always reachable without scrolling to the bottom. -->
492
537
  <div class="cpub-edit-actionbar">
@@ -506,7 +551,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
506
551
  </template>
507
552
 
508
553
  <style scoped>
509
- .cpub-contest-edit { max-width: 700px; margin: 0 auto; padding: 32px; }
554
+ .cpub-contest-edit { max-width: 1080px; margin: 0 auto; padding: 32px; }
510
555
  .cpub-back-link { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
511
556
  .cpub-back-link:hover { color: var(--accent); }
512
557
  .cpub-edit-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
@@ -522,15 +567,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
522
567
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
523
568
 
524
569
  .cpub-edit-form { display: flex; flex-direction: column; gap: 16px; }
570
+ /* Two-column editor: wide content column + a sticky meta rail (Stage & Status,
571
+ Entry rules, Danger Zone) so lifecycle controls stay reachable while editing. */
572
+ .cpub-edit-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
573
+ .cpub-edit-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
574
+ .cpub-edit-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 76px; }
525
575
  .cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
526
576
  .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 14px; }
527
- .cpub-form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
528
- .cpub-form-field:last-child { margin-bottom: 0; }
529
- .cpub-form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
530
- .cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
531
- .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
532
- .cpub-form-textarea { resize: vertical; }
533
- .cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
577
+ /* `.cpub-form-input/-textarea/-field/-row/-label` are sourced from the global
578
+ theme (forms.css) so the extracted ContestStagesEditor inherits them across the
579
+ Vue scoped-style boundary don't re-scope them here. */
534
580
 
535
581
  .cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
536
582
  .cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
@@ -583,9 +629,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
583
629
  border-top: 2px solid var(--border);
584
630
  box-shadow: var(--shadow-lg);
585
631
  }
632
+ .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-top: var(--border-width-default) solid var(--border); }
633
+ .cpub-advance-row:first-of-type { border-top: 0; }
634
+ .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
635
+ .cpub-advance-name i { color: var(--accent); font-size: 11px; }
636
+ .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; }
637
+ .cpub-advance-ctl .cpub-form-label { margin: 0; }
638
+ .cpub-advance-n { width: 80px; }
586
639
  .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
587
640
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
588
641
 
642
+ /* Collapse the meta rail under the main column on narrower viewports. */
643
+ @media (max-width: 900px) {
644
+ .cpub-edit-layout { grid-template-columns: 1fr; }
645
+ .cpub-edit-side { position: static; }
646
+ }
589
647
  @media (max-width: 768px) {
590
648
  .cpub-contest-edit { padding: 16px; }
591
649
  .cpub-form-row { grid-template-columns: 1fr; }
@@ -403,13 +403,8 @@ function prizeLabel(prize: Prize): string {
403
403
  .cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
404
404
  .cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
405
405
 
406
- .cpub-form-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
407
- .cpub-form-field:last-child { margin-bottom: 0; }
408
- .cpub-form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
409
- .cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
410
- .cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
411
- .cpub-form-textarea { resize: vertical; }
412
- .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
406
+ /* `.cpub-form-input/-textarea/-field/-row/-label` come from the global theme
407
+ (forms.css) shared with the extracted ContestStagesEditor. */
413
408
 
414
409
  .cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
415
410
  .cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
@@ -0,0 +1,22 @@
1
+ import { getContestBySlug, advanceContestStage } from '@commonpub/server';
2
+ import { contestAdvanceSchema } from '@commonpub/schema';
3
+
4
+ // Phase B2 — apply an advancement cut at a review stage (cull the cohort to top-N
5
+ // or a manual pick, snapshot scores, advance to the next stage). Owner-gated.
6
+ export default defineEventHandler(async (event): Promise<{ advanced: boolean; advancedCount: number; eliminatedCount: number }> => {
7
+ requireFeature('contests');
8
+ const db = useDB();
9
+ const user = requireAuth(event);
10
+ const { slug } = parseParams(event, { slug: 'string' });
11
+ const input = await parseBody(event, contestAdvanceSchema);
12
+
13
+ const contest = await getContestBySlug(db, slug);
14
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+
16
+ const result = await advanceContestStage(db, contest.id, user.id, input);
17
+ if (!result.advanced) {
18
+ const owner = /owner/i.test(result.error ?? '');
19
+ throw createError({ statusCode: owner ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
20
+ }
21
+ return result;
22
+ });
@@ -49,6 +49,50 @@ export function currentStageId(c: StageSource): string | null {
49
49
  }
50
50
  }
51
51
 
52
+ // ─── Pure stage-array operations (used by ContestStagesEditor; unit-tested) ───
53
+
54
+ export function newStageId(): string {
55
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
56
+ return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
57
+ }
58
+
59
+ export function blankStage(): ContestStage {
60
+ return { id: newStageId(), name: '', kind: 'custom' };
61
+ }
62
+
63
+ /** The three standard stages seeded when an operator chooses to customise. */
64
+ export function seedStandardStages(c: { startDate?: string | null; endDate?: string | null; judgingEndDate?: string | null }): ContestStage[] {
65
+ const i = (d?: string | null): string | undefined => (d ? new Date(d).toISOString() : undefined);
66
+ return [
67
+ { id: newStageId(), name: 'Submissions', kind: 'submission', startsAt: i(c.startDate), endsAt: i(c.endDate) },
68
+ { id: newStageId(), name: 'Judging', kind: 'review', endsAt: i(c.judgingEndDate) ?? i(c.endDate) },
69
+ { id: newStageId(), name: 'Results', kind: 'results' },
70
+ ];
71
+ }
72
+
73
+ export function withStageAdded(stages: ContestStage[]): ContestStage[] {
74
+ return [...stages, blankStage()];
75
+ }
76
+
77
+ export function withStageDuplicated(stages: ContestStage[], i: number): ContestStage[] {
78
+ const src = stages[i];
79
+ if (!src) return stages;
80
+ const copy: ContestStage = { ...src, id: newStageId(), name: `${src.name} (copy)`, core: false };
81
+ return [...stages.slice(0, i + 1), copy, ...stages.slice(i + 1)];
82
+ }
83
+
84
+ export function withStageRemoved(stages: ContestStage[], i: number): ContestStage[] {
85
+ return stages.filter((_, idx) => idx !== i);
86
+ }
87
+
88
+ export function withStageMoved(stages: ContestStage[], i: number, dir: -1 | 1): ContestStage[] {
89
+ const j = i + dir;
90
+ if (j < 0 || j >= stages.length) return stages;
91
+ const next = [...stages];
92
+ [next[i], next[j]] = [next[j]!, next[i]!];
93
+ return next;
94
+ }
95
+
52
96
  /** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
53
97
  export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
54
98
  submission: 'fa-pen-to-square',