@commonpub/layer 0.55.0 → 0.57.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.
@@ -45,6 +45,29 @@ function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
45
45
  setField(i, { [field]: v } as Partial<ContestStage>);
46
46
  }
47
47
 
48
+ // Per-round rubric (review stages). Immutable updates via setField.
49
+ type StageCriterion = { label: string; weight?: number; description?: string };
50
+ function addCriterion(i: number): void {
51
+ const cur = (stages.value[i]?.criteria ?? []) as StageCriterion[];
52
+ setField(i, { criteria: [...cur, { label: '' }] });
53
+ }
54
+ function setCriterion(i: number, ci: number, patch: Partial<StageCriterion>): void {
55
+ const cur = (stages.value[i]?.criteria ?? []).map((c, idx) => (idx === ci ? { ...c, ...patch } : c));
56
+ setField(i, { criteria: cur });
57
+ }
58
+ function removeCriterion(i: number, ci: number): void {
59
+ const cur = (stages.value[i]?.criteria ?? []).filter((_, idx) => idx !== ci);
60
+ setField(i, { criteria: cur.length ? cur : undefined });
61
+ }
62
+ function critWeightInput(i: number, ci: number, e: Event): void {
63
+ const v = (e.target as HTMLInputElement).value;
64
+ setCriterion(i, ci, { weight: v === '' ? undefined : Math.max(0, Math.min(100, Math.round(Number(v)))) });
65
+ }
66
+ function advanceCountInput(i: number, e: Event): void {
67
+ const v = (e.target as HTMLInputElement).value;
68
+ setField(i, { advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
69
+ }
70
+
48
71
  // Array operations live as pure functions in utils/contestStages.ts (unit-tested).
49
72
  function addStage(): void {
50
73
  commit(withStageAdded(stages.value));
@@ -147,6 +170,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
147
170
  </div>
148
171
  </div>
149
172
 
173
+ <p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
174
+
150
175
  <div class="cpub-form-row">
151
176
  <div class="cpub-form-field">
152
177
  <label class="cpub-form-label">Starts</label>
@@ -169,6 +194,24 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
169
194
  />
170
195
  </div>
171
196
 
197
+ <!-- Per-round config (review stages): how many advance + the rubric -->
198
+ <div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
199
+ <div class="cpub-form-field" style="margin-bottom: 10px;">
200
+ <label class="cpub-form-label">Advance the top N to the next stage</label>
201
+ <input :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50 — leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
202
+ </div>
203
+ <div class="cpub-stage-criteria-head">
204
+ <span class="cpub-form-label" style="margin: 0;">Judging criteria — this round</span>
205
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion(i)"><i class="fa-solid fa-plus"></i> Add</button>
206
+ </div>
207
+ <p class="cpub-form-hint" style="margin: 4px 0;">Optional — leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
208
+ <div v-for="(crit, ci) in (stage.criteria ?? [])" :key="ci" class="cpub-stage-crit-row">
209
+ <input :value="crit.label" type="text" class="cpub-form-input" placeholder="Criterion (e.g. Community impact)" @input="setCriterion(i, ci, { label: ($event.target as HTMLInputElement).value })" />
210
+ <input :value="crit.weight ?? ''" type="number" min="0" max="100" class="cpub-form-input cpub-stage-crit-pts" placeholder="pts" @input="critWeightInput(i, ci, $event)" />
211
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove criterion" @click="removeCriterion(i, ci)"><i class="fa-solid fa-xmark"></i></button>
212
+ </div>
213
+ </div>
214
+
172
215
  <div v-if="stage.kind === 'event'" class="cpub-form-row">
173
216
  <div class="cpub-form-field">
174
217
  <label class="cpub-form-label">Location</label>
@@ -216,4 +259,12 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
216
259
  .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
217
260
  .cpub-stage-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
218
261
  .cpub-stage-reset { color: var(--text-faint); }
262
+ .cpub-stage-kind-help { font-size: 11px; color: var(--text-faint); line-height: 1.5; margin: 0 0 4px; display: flex; gap: 6px; }
263
+ .cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
264
+ .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
265
+ .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
266
+ .cpub-stage-crit-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
267
+ .cpub-stage-crit-row .cpub-form-input { margin: 0; }
268
+ .cpub-stage-crit-pts { max-width: 70px; flex-shrink: 0; }
269
+ .cpub-stage-advn { max-width: 320px; }
219
270
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -56,13 +56,13 @@
56
56
  "@commonpub/auth": "0.8.0",
57
57
  "@commonpub/config": "0.18.0",
58
58
  "@commonpub/docs": "0.6.3",
59
- "@commonpub/explainer": "0.7.15",
60
- "@commonpub/protocol": "0.13.0",
61
59
  "@commonpub/editor": "0.7.11",
60
+ "@commonpub/protocol": "0.13.0",
62
61
  "@commonpub/learning": "0.5.2",
63
- "@commonpub/server": "2.77.0",
64
- "@commonpub/schema": "0.30.0",
65
- "@commonpub/ui": "0.9.2"
62
+ "@commonpub/server": "2.79.0",
63
+ "@commonpub/schema": "0.32.0",
64
+ "@commonpub/ui": "0.9.2",
65
+ "@commonpub/explainer": "0.7.15"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -60,6 +60,9 @@ const criteria = ref<Criterion[]>([]);
60
60
  // Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
61
61
  const stages = ref<ContestStage[]>([]);
62
62
  const currentStageIdRef = ref<string | null>(null);
63
+ // Declared before the contest loader (below) since the loader pre-fills advanceN.
64
+ const advancing = ref<string | null>(null);
65
+ const advanceN = ref<Record<string, number>>({});
63
66
 
64
67
  // Load current data
65
68
  watch(contest, (c) => {
@@ -83,6 +86,10 @@ watch(contest, (c) => {
83
86
  showPrizes.value = c.showPrizes !== false;
84
87
  stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
85
88
  currentStageIdRef.value = c.currentStageId ?? null;
89
+ // Pre-fill the Advancement control from each review stage's defined cut.
90
+ for (const s of stages.value) {
91
+ if (s.kind === 'review' && typeof s.advanceCount === 'number') advanceN.value[s.id] = s.advanceCount;
92
+ }
86
93
  prizesDescription.value = c.prizesDescription ?? '';
87
94
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
88
95
  place: p.place ?? null,
@@ -218,8 +225,6 @@ const statusAction = contestStatusAction;
218
225
 
219
226
  // Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
220
227
  // 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
228
  const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
224
229
  async function advanceStage(stageId: string): Promise<void> {
225
230
  const topN = advanceN.value[stageId];
@@ -15,9 +15,23 @@ const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: (Se
15
15
  { query: { includeJudgeScores: true } },
16
16
  );
17
17
 
18
- // Judging rubric: when defined, judges score each criterion (0..max) and the
19
- // overall is the normalized weighted sum (computed server-side).
20
- const criteria = computed(() => contest.value?.judgingCriteria ?? []);
18
+ // The current review stage (multi-round contests). Drives the round label + the
19
+ // per-round rubric; falls back to the contest-level rubric when the stage has none.
20
+ const currentReviewStage = computed(() => {
21
+ const c = contest.value;
22
+ if (!c || !c.stages?.length) return null;
23
+ const cid = currentStageId(c);
24
+ const st = c.stages.find((s) => s.id === cid);
25
+ return st && st.kind === 'review' ? st : null;
26
+ });
27
+
28
+ // Judging rubric: per-round criteria if the current review stage defines them,
29
+ // else the contest-level rubric. Judges score each criterion (0..max); the overall
30
+ // is the normalized weighted sum (computed server-side).
31
+ const criteria = computed(() => {
32
+ const stageCrit = currentReviewStage.value?.criteria;
33
+ return (stageCrit && stageCrit.length ? stageCrit : contest.value?.judgingCriteria) ?? [];
34
+ });
21
35
  const hasCriteria = computed(() => criteria.value.length > 0);
22
36
  function critMax(i: number): number {
23
37
  const w = criteria.value[i]?.weight;
@@ -48,7 +62,9 @@ async function acceptInvite(): Promise<void> {
48
62
  }
49
63
 
50
64
  const entryList = computed(() => {
51
- const items = entriesData.value?.items ?? [];
65
+ // Cohort scope: once a review stage has culled the field, judges only score the
66
+ // surviving cohort (eliminated entries drop out of later rounds).
67
+ const items = (entriesData.value?.items ?? []).filter((e) => !e.eliminated);
52
68
  return items.map((entry) => {
53
69
  const myScore = entry.judgeScores?.find((s) => s.judgeId === user.value?.id);
54
70
  return {
@@ -161,8 +177,12 @@ async function submitScore(entryId: string): Promise<void> {
161
177
  <h1 class="cpub-judge-title">
162
178
  <i class="fa-solid fa-gavel cpub-judge-icon"></i>
163
179
  Judge: {{ contest?.title || 'Contest' }}
180
+ <span v-if="currentReviewStage" class="cpub-judge-round">{{ currentReviewStage.name }}</span>
164
181
  </h1>
165
- <p class="cpub-judge-desc">Score each entry from 1 to 100. Add optional feedback. Scores are saved immediately.</p>
182
+ <p class="cpub-judge-desc">
183
+ Score each entry from 1 to 100. Add optional feedback. Scores are saved immediately.
184
+ <template v-if="currentReviewStage"> You're judging the <strong>{{ entryList.length }}</strong> {{ entryList.length === 1 ? 'entry' : 'entries' }} still in this round.</template>
185
+ </p>
166
186
  </header>
167
187
 
168
188
  <!-- Loading -->
@@ -200,9 +220,9 @@ async function submitScore(entryId: string): Promise<void> {
200
220
  Scoring opens when the contest enters the judging phase (currently <strong>{{ contest.status }}</strong>).
201
221
  </div>
202
222
 
203
- <!-- Rubric guidance -->
204
- <div v-if="contest.judgingCriteria?.length" class="cpub-judge-rubric">
205
- <ContestJudgingCriteria :criteria="contest.judgingCriteria" compact />
223
+ <!-- Rubric guidance (per-round criteria when the current review stage defines them) -->
224
+ <div v-if="criteria.length" class="cpub-judge-rubric">
225
+ <ContestJudgingCriteria :criteria="criteria" compact />
206
226
  </div>
207
227
 
208
228
  <!-- Progress bar -->
@@ -298,6 +318,7 @@ async function submitScore(entryId: string): Promise<void> {
298
318
  .cpub-judge-back:hover { color: var(--accent); }
299
319
  .cpub-judge-title { font-size: 20px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
300
320
  .cpub-judge-icon { color: var(--accent); font-size: 18px; }
321
+ .cpub-judge-round { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .08em; color: var(--accent); border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); padding: 3px 9px; border-radius: var(--radius); }
301
322
  .cpub-judge-desc { font-size: 13px; color: var(--text-dim); margin-top: 6px; }
302
323
 
303
324
  .cpub-judge-unauthorized { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
@@ -112,3 +112,14 @@ export const STAGE_KIND_LABEL: Record<ContestStage['kind'], string> = {
112
112
  event: 'Event / Showcase',
113
113
  custom: 'Custom milestone',
114
114
  };
115
+
116
+ /** What each stage kind actually DOES — shown under the editor's type picker so
117
+ * organisers understand the behaviour they're choosing. */
118
+ export const STAGE_KIND_HELP: Record<ContestStage['kind'], string> = {
119
+ submission: 'Entrants submit (or, in a later round, refine) entries. The hero countdown targets this stage’s end date.',
120
+ review: 'Judges score entries on a rubric. End a review stage with an Advancement cut (Top-N) to pick who continues. Add per-round criteria below for multi-round contests.',
121
+ interim: 'A working period — e.g. a build sprint. The surviving cohort refines their existing entries; no new entrants.',
122
+ results: 'Final standings are published (ranks calculated from the latest judging round).',
123
+ event: 'A real-world milestone or showcase (date + location). Informational — no entry/judging behaviour.',
124
+ custom: 'An arbitrary dated milestone. No behaviour — just appears on the timeline.',
125
+ };