🪻 distributed transcription service thistle.dunkirk.sh

feat: redesign meeting times with visual day picker

Replaced text-based meeting time inputs with an intuitive visual day picker:
- New meeting-time-picker component with clickable day buttons (Mon-Sun)
- Click to select/deselect days when class meets
- Selected days highlight with primary color
- No time inputs needed - just tracks which days
- Auto-generates labels from selected days (e.g., "Monday", "Tuesday")

Student workflow:
- Waitlist form uses meeting-time-picker
- Click days when their class meets
- Data stored as JSON array of day objects

Admin workflow:
- Approval modal shows all editable fields (not just meeting times)
- Course code, name, professor, semester, year all editable
- Meeting time picker pre-filled with student's selections
- Admin can edit any field before creating class
- Error messages now display inside modal (not behind it)
- Better error logging for debugging

Technical improvements:
- Fixed SQL query to remove section column reference
- Proper Lit reactivity with updated() lifecycle
- Meeting times load correctly from waitlist data
- Clean separation between MeetingTime interface and picker component

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

dunkirk.sh 9631d4de 14510882

verified
+149 -70
src/components/admin-classes.ts
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
interface Class {
id: string;
···
@state() showCreateModal = false;
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
-
@state() meetingTimes: string[] = [""];
static override styles = css`
:host {
···
box-sizing: border-box;
}
-
.form-group input:focus {
outline: none;
border-color: var(--primary);
}
.meeting-times-list {
display: flex;
flex-direction: column;
···
private handleApproveWaitlist(entry: WaitlistEntry) {
this.approvingEntry = entry;
// Parse meeting times from JSON if available, otherwise use empty array
if (entry.meeting_times) {
try {
const parsed = JSON.parse(entry.meeting_times);
-
this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [""];
} catch {
-
this.meetingTimes = [""];
}
} else {
-
this.meetingTimes = [""];
}
}
-
private addMeetingTime() {
-
this.meetingTimes = [...this.meetingTimes, ""];
-
}
-
-
private removeMeetingTime(index: number) {
-
this.meetingTimes = this.meetingTimes.filter((_, i) => i !== index);
}
-
private updateMeetingTime(index: number, value: string) {
-
this.meetingTimes = this.meetingTimes.map((time, i) =>
-
i === index ? value : time,
-
);
}
private cancelApproval() {
this.approvingEntry = null;
-
this.meetingTimes = [""];
}
private async submitApproval() {
if (!this.approvingEntry) return;
-
const entry = this.approvingEntry;
-
const times = this.meetingTimes.filter((t) => t.trim() !== "");
-
-
if (times.length === 0) {
this.error = "Please add at least one meeting time";
return;
}
try {
const response = await fetch("/api/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
course_code: entry.course_code,
-
name: entry.course_name,
-
professor: entry.professor,
-
semester: entry.semester,
-
year: entry.year,
-
meeting_times: times,
}),
});
if (!response.ok) {
-
throw new Error("Failed to create class");
}
-
await fetch(`/api/admin/waitlist/${entry.id}`, {
method: "DELETE",
});
···
this.activeTab = "classes";
this.approvingEntry = null;
-
this.meetingTimes = [""];
-
} catch {
-
this.error = "Failed to approve waitlist entry. Please try again.";
}
}
private renderApprovalModal() {
if (!this.approvingEntry) return "";
-
const entry = this.approvingEntry;
-
return html`
<div class="modal-overlay" @click=${this.cancelApproval}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
-
<h2 class="modal-title">Add Meeting Times</h2>
<p style="margin-bottom: 1.5rem; color: var(--paynes-gray);">
-
Creating class: <strong>${entry.course_code} - ${entry.course_name}</strong>
</p>
-
<div class="form-group">
-
<label>Meeting Times</label>
-
<div class="meeting-times-list">
-
${this.meetingTimes.map(
-
(time, index) => html`
-
<div class="meeting-time-row">
-
<input
-
type="text"
-
placeholder="e.g., Monday Lecture, Wednesday Lab"
-
.value=${time}
-
@input=${(e: Event) =>
-
this.updateMeetingTime(
-
index,
-
(e.target as HTMLInputElement).value,
-
)}
-
/>
-
${
-
this.meetingTimes.length > 1
-
? html`
-
<button
-
class="btn-remove"
-
@click=${() => this.removeMeetingTime(index)}
-
>
-
Remove
-
</button>
-
`
-
: ""
-
}
-
</div>
-
`,
-
)}
-
<button class="btn-add" @click=${this.addMeetingTime}>
-
+ Add Meeting Time
-
</button>
</div>
</div>
···
<button
class="btn-submit"
@click=${this.submitApproval}
-
?disabled=${this.meetingTimes.every((t) => t.trim() === "")}
>
Create Class
</button>
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
+
import type { MeetingTime } from "./meeting-time-picker";
+
import "./meeting-time-picker";
interface Class {
id: string;
···
@state() showCreateModal = false;
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
+
@state() meetingTimes: MeetingTime[] = [];
+
@state() editingClass = {
+
courseCode: "",
+
courseName: "",
+
professor: "",
+
semester: "",
+
year: new Date().getFullYear(),
+
};
static override styles = css`
:host {
···
box-sizing: border-box;
}
+
.form-group input:focus,
+
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
+
.form-group select {
+
width: 100%;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
box-sizing: border-box;
+
}
+
+
.form-grid {
+
display: grid;
+
grid-template-columns: 1fr 1fr;
+
gap: 1rem;
+
margin-bottom: 1rem;
+
}
+
+
.form-group-full {
+
grid-column: 1 / -1;
+
}
+
.meeting-times-list {
display: flex;
flex-direction: column;
···
private handleApproveWaitlist(entry: WaitlistEntry) {
this.approvingEntry = entry;
+
// Pre-fill form with waitlist data
+
this.editingClass = {
+
courseCode: entry.course_code,
+
courseName: entry.course_name,
+
professor: entry.professor,
+
semester: entry.semester,
+
year: entry.year,
+
};
+
// Parse meeting times from JSON if available, otherwise use empty array
if (entry.meeting_times) {
try {
const parsed = JSON.parse(entry.meeting_times);
+
this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [];
} catch {
+
this.meetingTimes = [];
}
} else {
+
this.meetingTimes = [];
}
}
+
private handleMeetingTimesChange(e: CustomEvent) {
+
this.meetingTimes = e.detail;
}
+
private handleClassFieldInput(field: string, e: Event) {
+
const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
+
this.editingClass = { ...this.editingClass, [field]: value };
}
private cancelApproval() {
this.approvingEntry = null;
+
this.meetingTimes = [];
+
this.editingClass = {
+
courseCode: "",
+
courseName: "",
+
professor: "",
+
semester: "",
+
year: new Date().getFullYear(),
+
};
}
private async submitApproval() {
if (!this.approvingEntry) return;
+
if (this.meetingTimes.length === 0) {
this.error = "Please add at least one meeting time";
return;
}
+
// Convert MeetingTime objects to label strings
+
const labels = this.meetingTimes.map((t) => t.label);
+
try {
const response = await fetch("/api/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+
course_code: this.editingClass.courseCode,
+
name: this.editingClass.courseName,
+
professor: this.editingClass.professor,
+
semester: this.editingClass.semester,
+
year: this.editingClass.year,
+
meeting_times: labels,
}),
});
if (!response.ok) {
+
const data = await response.json();
+
console.error("Failed to create class:", data);
+
throw new Error(data.error || "Failed to create class");
}
+
await fetch(`/api/admin/waitlist/${this.approvingEntry.id}`, {
method: "DELETE",
});
···
this.activeTab = "classes";
this.approvingEntry = null;
+
this.meetingTimes = [];
+
this.editingClass = {
+
courseCode: "",
+
courseName: "",
+
professor: "",
+
semester: "",
+
year: new Date().getFullYear(),
+
};
+
} catch (error) {
+
console.error("Error in submitApproval:", error);
+
this.error = error instanceof Error ? error.message : "Failed to approve waitlist entry. Please try again.";
}
}
private renderApprovalModal() {
if (!this.approvingEntry) return "";
return html`
<div class="modal-overlay" @click=${this.cancelApproval}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
+
<h2 class="modal-title">Review & Create Class</h2>
<p style="margin-bottom: 1.5rem; color: var(--paynes-gray);">
+
Review the class details and make any edits before creating
</p>
+
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
+
+
<div class="form-grid">
+
<div class="form-group">
+
<label>Course Code *</label>
+
<input
+
type="text"
+
required
+
.value=${this.editingClass.courseCode}
+
@input=${(e: Event) => this.handleClassFieldInput("courseCode", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Course Name *</label>
+
<input
+
type="text"
+
required
+
.value=${this.editingClass.courseName}
+
@input=${(e: Event) => this.handleClassFieldInput("courseName", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Professor *</label>
+
<input
+
type="text"
+
required
+
.value=${this.editingClass.professor}
+
@input=${(e: Event) => this.handleClassFieldInput("professor", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Semester *</label>
+
<select
+
required
+
.value=${this.editingClass.semester}
+
@change=${(e: Event) => this.handleClassFieldInput("semester", e)}
+
>
+
<option value="">Select semester</option>
+
<option value="Spring">Spring</option>
+
<option value="Summer">Summer</option>
+
<option value="Fall">Fall</option>
+
<option value="Winter">Winter</option>
+
</select>
+
</div>
+
<div class="form-group">
+
<label>Year *</label>
+
<input
+
type="number"
+
required
+
min="2020"
+
max="2030"
+
.value=${this.editingClass.year.toString()}
+
@input=${(e: Event) => this.handleClassFieldInput("year", e)}
+
/>
+
</div>
+
<div class="form-group form-group-full">
+
<label>Meeting Times *</label>
+
<meeting-time-picker
+
.value=${this.meetingTimes}
+
@change=${this.handleMeetingTimesChange}
+
></meeting-time-picker>
</div>
</div>
···
<button
class="btn-submit"
@click=${this.submitApproval}
+
?disabled=${this.meetingTimes.length === 0}
>
Create Class
</button>
+10 -113
src/components/class-registration-modal.ts
···
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
interface ClassResult {
id: string;
···
semester: "",
year: new Date().getFullYear(),
additionalInfo: "",
-
meetingTimes: [""],
};
static override styles = css`
···
padding: 2rem;
color: var(--paynes-gray);
}
-
-
.meeting-times-list {
-
display: flex;
-
flex-direction: column;
-
gap: 0.5rem;
-
}
-
-
.meeting-time-row {
-
display: flex;
-
gap: 0.5rem;
-
align-items: center;
-
}
-
-
.meeting-time-row input {
-
flex: 1;
-
}
-
-
.btn-remove {
-
padding: 0.5rem 1rem;
-
background: transparent;
-
color: #dc2626;
-
border: 2px solid #dc2626;
-
border-radius: 6px;
-
cursor: pointer;
-
font-size: 0.875rem;
-
font-weight: 500;
-
transition: all 0.2s;
-
font-family: inherit;
-
}
-
-
.btn-remove:hover {
-
background: #dc2626;
-
color: white;
-
}
-
-
.btn-add {
-
margin-top: 0.5rem;
-
padding: 0.5rem 1rem;
-
background: transparent;
-
color: var(--primary);
-
border: 2px solid var(--primary);
-
border-radius: 6px;
-
cursor: pointer;
-
font-size: 0.875rem;
-
font-weight: 500;
-
transition: all 0.2s;
-
font-family: inherit;
-
}
-
-
.btn-add:hover {
-
background: var(--primary);
-
color: white;
-
}
`;
private handleClose() {
···
semester: "",
year: new Date().getFullYear(),
additionalInfo: "",
-
meetingTimes: [""],
};
this.dispatchEvent(new CustomEvent("close"));
}
···
this.showWaitlistForm = false;
}
-
private addMeetingTime() {
this.waitlistData = {
...this.waitlistData,
-
meetingTimes: [...this.waitlistData.meetingTimes, ""],
};
-
}
-
-
private removeMeetingTime(index: number) {
-
this.waitlistData = {
-
...this.waitlistData,
-
meetingTimes: this.waitlistData.meetingTimes.filter(
-
(_, i) => i !== index,
-
),
-
};
-
}
-
-
private updateMeetingTime(index: number, value: string) {
-
const newTimes = [...this.waitlistData.meetingTimes];
-
newTimes[index] = value;
-
this.waitlistData = { ...this.waitlistData, meetingTimes: newTimes };
}
override render() {
···
</div>
<div class="form-group form-group-full">
<label>Meeting Times *</label>
-
<div class="meeting-times-list">
-
${this.waitlistData.meetingTimes.map(
-
(time, index) => html`
-
<div class="meeting-time-row">
-
<input
-
type="text"
-
required
-
placeholder="e.g., Monday Lecture, Wednesday Lecture"
-
.value=${time}
-
@input=${(e: Event) =>
-
this.updateMeetingTime(
-
index,
-
(e.target as HTMLInputElement).value,
-
)}
-
@keydown=${(e: KeyboardEvent) => {
-
if (e.key === "Enter") {
-
e.preventDefault();
-
this.addMeetingTime();
-
}
-
}}
-
/>
-
${
-
this.waitlistData.meetingTimes.length > 1
-
? html`
-
<button
-
type="button"
-
class="btn-remove"
-
@click=${() => this.removeMeetingTime(index)}
-
>
-
Remove
-
</button>
-
`
-
: ""
-
}
-
</div>
-
`,
-
)}
-
<button type="button" class="btn-add" @click=${this.addMeetingTime}>
-
+ Add Meeting Time
-
</button>
-
</div>
</div>
<div class="form-group form-group-full">
<label>Additional Info (optional)</label>
···
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
+
import type { MeetingTime } from "./meeting-time-picker";
+
import "./meeting-time-picker";
interface ClassResult {
id: string;
···
semester: "",
year: new Date().getFullYear(),
additionalInfo: "",
+
meetingTimes: [] as MeetingTime[],
};
static override styles = css`
···
padding: 2rem;
color: var(--paynes-gray);
}
`;
private handleClose() {
···
semester: "",
year: new Date().getFullYear(),
additionalInfo: "",
+
meetingTimes: [],
};
this.dispatchEvent(new CustomEvent("close"));
}
···
this.showWaitlistForm = false;
}
+
private handleMeetingTimesChange(e: CustomEvent) {
this.waitlistData = {
...this.waitlistData,
+
meetingTimes: e.detail,
};
}
override render() {
···
</div>
<div class="form-group form-group-full">
<label>Meeting Times *</label>
+
<meeting-time-picker
+
.value=${this.waitlistData.meetingTimes}
+
@change=${this.handleMeetingTimesChange}
+
></meeting-time-picker>
</div>
<div class="form-group form-group-full">
<label>Additional Info (optional)</label>
+150
src/components/meeting-time-picker.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
+
export interface MeetingTime {
+
day: string;
+
startTime: string;
+
endTime: string;
+
label: string;
+
}
+
+
interface DayState {
+
day: string;
+
shortName: string;
+
selected: boolean;
+
}
+
+
@customElement("meeting-time-picker")
+
export class MeetingTimePicker extends LitElement {
+
@property({ type: Array }) value: MeetingTime[] = [];
+
+
@state() private days: DayState[] = [
+
{ day: "Monday", shortName: "Mon", selected: false },
+
{ day: "Tuesday", shortName: "Tue", selected: false },
+
{ day: "Wednesday", shortName: "Wed", selected: false },
+
{ day: "Thursday", shortName: "Thu", selected: false },
+
{ day: "Friday", shortName: "Fri", selected: false },
+
{ day: "Saturday", shortName: "Sat", selected: false },
+
{ day: "Sunday", shortName: "Sun", selected: false },
+
];
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.day-selector {
+
display: flex;
+
gap: 0.5rem;
+
flex-wrap: wrap;
+
}
+
+
.day-button {
+
flex: 1;
+
min-width: 3.5rem;
+
padding: 0.75rem 0.5rem;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
color: var(--text);
+
}
+
+
.day-button:hover {
+
border-color: var(--primary);
+
}
+
+
.day-button.selected {
+
background: var(--primary);
+
border-color: var(--primary);
+
color: white;
+
}
+
+
.helper-text {
+
margin-top: 0.5rem;
+
font-size: 0.75rem;
+
color: var(--paynes-gray);
+
}
+
`;
+
+
override connectedCallback() {
+
super.connectedCallback();
+
this.loadFromValue();
+
}
+
+
override updated(changedProperties: Map<string, unknown>) {
+
if (changedProperties.has("value")) {
+
this.loadFromValue();
+
}
+
}
+
+
private loadFromValue() {
+
// Always reset all days first
+
this.days = this.days.map((d) => ({ ...d, selected: false }));
+
+
// If no value, we're done
+
if (!this.value || this.value.length === 0) return;
+
+
// Load from value
+
for (const meeting of this.value) {
+
const dayIndex = this.days.findIndex((d) => d.day === meeting.day);
+
if (dayIndex !== -1) {
+
this.days = this.days.map((d, i) =>
+
i === dayIndex ? { ...d, selected: true } : d,
+
);
+
}
+
}
+
}
+
+
private toggleDay(index: number) {
+
this.days = this.days.map((d, i) =>
+
i === index ? { ...d, selected: !d.selected } : d,
+
);
+
this.dispatchChange();
+
}
+
+
private dispatchChange() {
+
const selectedDays = this.days
+
.filter((d) => d.selected)
+
.map((d) => ({
+
day: d.day,
+
startTime: "",
+
endTime: "",
+
label: d.day,
+
}));
+
+
this.dispatchEvent(
+
new CustomEvent("change", {
+
detail: selectedDays,
+
bubbles: true,
+
composed: true,
+
}),
+
);
+
}
+
+
override render() {
+
return html`
+
<div class="day-selector">
+
${this.days.map(
+
(day, index) => html`
+
<button
+
type="button"
+
class="day-button ${day.selected ? "selected" : ""}"
+
@click=${() => this.toggleDay(index)}
+
>
+
${day.shortName}
+
</button>
+
`,
+
)}
+
</div>
+
+
<div class="helper-text">
+
Click on days to select when this class meets
+
</div>
+
`;
+
}
+
}
+1 -1
src/lib/classes.ts
···
`SELECT * FROM classes
WHERE UPPER(course_code) LIKE UPPER(?)
AND archived = 0
-
ORDER BY year DESC, semester DESC, professor ASC, section ASC`,
)
.all(`%${courseCode}%`);
}
···
`SELECT * FROM classes
WHERE UPPER(course_code) LIKE UPPER(?)
AND archived = 0
+
ORDER BY year DESC, semester DESC, professor ASC`,
)
.all(`%${courseCode}%`);
}