diff --git a/app/src/lib/users/Student.svelte b/app/src/lib/users/Student.svelte index ae8e358..125dc3c 100644 --- a/app/src/lib/users/Student.svelte +++ b/app/src/lib/users/Student.svelte @@ -7,7 +7,7 @@ $: ({ email, given_name, family_name, avatar, student_number, labs, lab_id } = user); - + {#if given_name.length > 0 && family_name.length > 0} diff --git a/app/src/routes/dashboard/(admin)/drafts/+layout.svelte b/app/src/routes/dashboard/(admin)/drafts/+layout.svelte new file mode 100644 index 0000000..485e2a2 --- /dev/null +++ b/app/src/routes/dashboard/(admin)/drafts/+layout.svelte @@ -0,0 +1,27 @@ + + +{#if draft !== null} + {@const { draft_id, curr_round, max_rounds, active_period_start } = draft} + {@const startDate = format(active_period_start, 'PPP')} + {@const startTime = format(active_period_start, 'pp')} +
+

+ {#if curr_round === null} + Draft #{draft_id} (which opened last {startDate} at + {startTime}) has recently finished the main drafting process. It is currently in the + lottery rounds. + {:else} + Draft #{draft_id} is currently on Round {curr_round} + of {max_rounds}. It opened last {startDate} at + {startTime}. + {/if} +

+
+{/if} + diff --git a/app/src/routes/dashboard/(admin)/drafts/+page.server.ts b/app/src/routes/dashboard/(admin)/drafts/+page.server.ts index 8e5ab37..1023b98 100644 --- a/app/src/routes/dashboard/(admin)/drafts/+page.server.ts +++ b/app/src/routes/dashboard/(admin)/drafts/+page.server.ts @@ -9,11 +9,15 @@ export async function load({ locals: { db }, parent }) { if (!user.is_admin || user.user_id === null || user.lab_id !== null) error(403); const labs = await db.getLabRegistry(); - if (draft === null) return { draft: null, labs }; + if (draft === null) return { draft: null, labs, available: [], selected: [], records: [] }; + + const [students, records] = await Promise.all([ + db.getStudentsInDraftTaggedByLab(draft.draft_id), + db.getFacultyChoiceRecords(draft.draft_id), + ]); - const students = await db.getStudentsInDraftTaggedByLab(draft.draft_id); const { available, selected } = groupBy(students, ({ lab_id }) => (lab_id === null ? 'available' : 'selected')); - return { draft, labs, available: available ?? [], selected: selected ?? [] }; + return { draft, labs, available: available ?? [], selected: selected ?? [], records }; } function* mapRowTuples(data: FormData) { diff --git a/app/src/routes/dashboard/(admin)/drafts/+page.svelte b/app/src/routes/dashboard/(admin)/drafts/+page.svelte index b96145c..a113d88 100644 --- a/app/src/routes/dashboard/(admin)/drafts/+page.svelte +++ b/app/src/routes/dashboard/(admin)/drafts/+page.svelte @@ -1,8 +1,6 @@ -{#if data.draft === null} +{#if draft === null} {#if labs.some(({ quota }) => quota > 0)}
@@ -32,91 +30,70 @@
{:else} - The total quota of all labs is currently zero. Please
allocate at least one slot to a lab to proceed. at least one slot to a lab to proceed. {/if} -{:else} - {@const { - draft: { draft_id, curr_round, max_rounds, active_period_start }, - available, - selected, - } = data} - {@const startDate = format(active_period_start, 'PPP')} - {@const startTime = format(active_period_start, 'pp')} -
-

- {#if curr_round === null} - Draft #{draft_id} (which opened last {startDate} at - {startTime}) has recently finished the main drafting process. It is currently in the - lottery rounds. - {:else} - Draft #{draft_id} is currently on Round {curr_round} - of {max_rounds}. It opened last {startDate} at - {startTime}. - {/if} -

-
- {#if curr_round === null} -
-
-

Lottery

-

- Draft #{draft_id} is almost done! The final stage is the lottery phase, where the remaining undrafted - students are randomly assigned to their labs. Before the system automatically randomizes anything, administrators - are given a final chance to manually intervene with the draft results. -

-
    -
  • - The "Eligible for Lottery" section features a list of the remaining undrafted students. - Administrators may negotiate with the lab heads on how to manually assign and distribute these students - fairly among interested labs. -
  • -
  • - Meanwhile, the "Already Drafted" section features an immutable list of - students who have already been drafted into their respective labs. These are considered final. -
  • +{:else if draft.curr_round === null} +
    +
    +

    Lottery

    +

    + Draft #{draft.draft_id} is almost done! The final stage is the lottery phase, where the remaining undrafted + students are randomly assigned to their labs. Before the system automatically randomizes anything, administrators + are given a final chance to manually intervene with the draft results. +

    +
      +
    • + The "Eligible for Lottery" section features a list of the remaining undrafted students. + Administrators may negotiate with the lab heads on how to manually assign and distribute these students + fairly among interested labs. +
    • +
    • + Meanwhile, the "Already Drafted" section features an immutable list of students + who have already been drafted into their respective labs. These are considered final. +
    • +
    +

    + + When ready, administrators can press the "Conclude Draft" button to proceed with the randomization + stage. The list of students will be randomly shuffled and distributed among the labs in a round-robin fashion. + To uphold fairness, it is important that uneven distributions are manually resolved beforehand. +

    +

    + After the randomization stage, the draft process is officially complete. All students, lab heads, and + administrators are notified of the final results. +

    + +
    +
    + +
    -
    - - -
    +
    - {:else if curr_round > 0} - - {:else if available.length > 0} -
    -
    +
    +{:else if draft.curr_round > 0} + +{:else} +
    +
    + {#if available.length > 0}

    Registered Students

    @@ -127,25 +104,24 @@

    Lab heads will be notified when the first round begins. The draft proceeds to the next round when all lab heads have submitted their preferences. This process repeats until the configured - maximum number of rounds has elapsed, after which the draft pauses until an administrator manually proceeds with the lottery stage. + maximum number of rounds has elapsed, after which the draft pauses until an administrator + manually proceeds with the lottery stage.

    - -
    - + + {:else} + No students have registered for this draft yet. The draft cannot proceed until at least one student + participates. + {/if}
    - {:else} - No students have registered for this draft yet. This draft cannot proceed to the next round until at least - one student registers. - {/if} + +
    {/if} diff --git a/app/src/routes/dashboard/(admin)/drafts/Dashboard.svelte b/app/src/routes/dashboard/(admin)/drafts/Dashboard.svelte new file mode 100644 index 0000000..931cea2 --- /dev/null +++ b/app/src/routes/dashboard/(admin)/drafts/Dashboard.svelte @@ -0,0 +1,86 @@ + + + + + + + + Registered Students + + + + Laboratories + + + + System Logs + + + {#if group === TabType.Students} + + + + Pending Selection ({available.length}/{total}) + + {#each available as student} + + {/each} + + + + + Already Drafted ({selected.length}/{total}) + + {#each selected as student} + + {/each} + + + + {:else if group === TabType.Labs} + + {#each labs as lab (lab.lab_id)} +
    + +
    + {/each} +
    + {:else if group === TabType.Logs} + + {:else} + This is an unexpected tab state. Kindly report this bug. + {/if} +
    +
    diff --git a/app/src/routes/dashboard/(admin)/drafts/LabAccordionItem.svelte b/app/src/routes/dashboard/(admin)/drafts/LabAccordionItem.svelte new file mode 100644 index 0000000..6554c09 --- /dev/null +++ b/app/src/routes/dashboard/(admin)/drafts/LabAccordionItem.svelte @@ -0,0 +1,72 @@ + + + +
    + {#if lab.quota === 0} +
    {lab.lab_name}
    + {:else if selected.length !== lab.quota} +
    {lab.lab_name}
    + {:else} +
    {lab.lab_name}
    + {/if} + + {selected.length} {isOpen ? 'members' : ''} + {preferred.length} {isOpen ? 'preferred' : ''} + {lab.quota} {isOpen ? 'maximum' : ''} + +
    +
    +
    +
    +
    + Members / Already Selected + {#each selected as student (student.email)} + + {:else} +
    No students selected yet.
    + {/each} +
    +
    + Preferred This Round + {#each preferred as student (student.email)} + + {:else} +
    No students prefer this lab for this round.
    + {/each} +
    +
    + Interested in Future Rounds + {#each interested as student (student.email)} + + {:else} +
    No remaining students are interested in this lab.
    + {/each} +
    +
    +
    +
    diff --git a/app/src/routes/dashboard/(admin)/drafts/SystemLogsTab.svelte b/app/src/routes/dashboard/(admin)/drafts/SystemLogsTab.svelte new file mode 100644 index 0000000..7580ccc --- /dev/null +++ b/app/src/routes/dashboard/(admin)/drafts/SystemLogsTab.svelte @@ -0,0 +1,74 @@ + + + + +
    + +
    +{#each events as [unix, choices]} + {@const labs = [...new Set(choices.map(({ lab_id }) => lab_id))]} +
    +
    + {fromUnixTime(unix).toLocaleString()} +
    + {#each labs as labId} + {@const labChoices = choices.filter(({ lab_id: choiceLab }) => choiceLab === labId)} + {@const [choice] = labChoices} + {#if typeof choice !== 'undefined'} +
    + {labId} (Round {choice.round ?? 'Lottery'}): + {#if choice.faculty_email === null || choice.student_email === null} + {#if choice.faculty_email === null} + + This selection was automated by the system + {:else} + + This selection of no students was + performed by faculty member + {choice.faculty_email} + {/if} + {:else} + + This selection of + {#each labChoices as { student_email }} + {student_email} + {/each} + was performed by + {choice.faculty_email} + {/if} +
    + {/if} + {/each} +
    +{/each} diff --git a/database/src/database.ts b/database/src/database.ts index f0ed7dc..4bee1cc 100644 --- a/database/src/database.ts +++ b/database/src/database.ts @@ -23,6 +23,13 @@ import { User } from 'drap-model/user'; const AvailableLabs = array(pick(Lab, ['lab_id', 'lab_name'])); const BooleanResult = object({ result: boolean() }); +const ChoiceRecords = array( + object({ + ...pick(FacultyChoice, ['created_at', 'lab_id', 'round', 'draft_id']).entries, + faculty_email: nullable(User.entries.email), + student_email: nullable(User.entries.email), + }), +); const CountResult = object({ count: bigint() }); const CreatedLab = pick(Lab, ['lab_id']); const CreatedDraft = pick(Draft, ['draft_id', 'active_period_start']); @@ -77,6 +84,7 @@ const UpsertedOpenIdUser = pick(User, ['is_admin', 'lab_id']); const UserEmails = array(pick(User, ['email'])); export type AvailableLabs = InferOutput; +export type ChoiceRecords = InferOutput; export type QueriedCandidateSenders = InferOutput; export type QueriedFaculty = InferOutput; export type RegisteredLabs = InferOutput; @@ -540,6 +548,13 @@ export class Database implements Loggable { return parse(CountResult, first).count; } + @timed async getFacultyChoiceRecords(draft: Draft['draft_id']) { + const sql = this.#sql; + const choices = + await sql`SELECT fc.draft_id, fc.round, fc.lab_id, fc.created_at, fc.faculty_email, fce.student_email FROM drap.faculty_choices fc LEFT JOIN drap.faculty_choices_emails fce ON (fc.draft_id, fc.round, fc.lab_id) = (fce.draft_id, fce.round, fce.lab_id) WHERE fc.draft_id = ${draft} ORDER BY fc.round`; + return parse(ChoiceRecords, choices); + } + @timed async getDraftEvents(draft: Draft['draft_id']) { const sql = this.#sql; const events =