🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+310 -184
+149 -70
src/components/admin-classes.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 + import type { MeetingTime } from "./meeting-time-picker"; 4 + import "./meeting-time-picker"; 3 5 4 6 interface Class { 5 7 id: string; ··· 35 37 @state() showCreateModal = false; 36 38 @state() activeTab: "classes" | "waitlist" = "classes"; 37 39 @state() approvingEntry: WaitlistEntry | null = null; 38 - @state() meetingTimes: string[] = [""]; 40 + @state() meetingTimes: MeetingTime[] = []; 41 + @state() editingClass = { 42 + courseCode: "", 43 + courseName: "", 44 + professor: "", 45 + semester: "", 46 + year: new Date().getFullYear(), 47 + }; 39 48 40 49 static override styles = css` 41 50 :host { ··· 316 325 box-sizing: border-box; 317 326 } 318 327 319 - .form-group input:focus { 328 + .form-group input:focus, 329 + .form-group select:focus { 320 330 outline: none; 321 331 border-color: var(--primary); 322 332 } 323 333 334 + .form-group select { 335 + width: 100%; 336 + padding: 0.75rem; 337 + border: 2px solid var(--secondary); 338 + border-radius: 6px; 339 + font-size: 1rem; 340 + font-family: inherit; 341 + background: var(--background); 342 + color: var(--text); 343 + box-sizing: border-box; 344 + } 345 + 346 + .form-grid { 347 + display: grid; 348 + grid-template-columns: 1fr 1fr; 349 + gap: 1rem; 350 + margin-bottom: 1rem; 351 + } 352 + 353 + .form-group-full { 354 + grid-column: 1 / -1; 355 + } 356 + 324 357 .meeting-times-list { 325 358 display: flex; 326 359 flex-direction: column; ··· 691 724 private handleApproveWaitlist(entry: WaitlistEntry) { 692 725 this.approvingEntry = entry; 693 726 727 + // Pre-fill form with waitlist data 728 + this.editingClass = { 729 + courseCode: entry.course_code, 730 + courseName: entry.course_name, 731 + professor: entry.professor, 732 + semester: entry.semester, 733 + year: entry.year, 734 + }; 735 + 694 736 // Parse meeting times from JSON if available, otherwise use empty array 695 737 if (entry.meeting_times) { 696 738 try { 697 739 const parsed = JSON.parse(entry.meeting_times); 698 - this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [""]; 740 + this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; 699 741 } catch { 700 - this.meetingTimes = [""]; 742 + this.meetingTimes = []; 701 743 } 702 744 } else { 703 - this.meetingTimes = [""]; 745 + this.meetingTimes = []; 704 746 } 705 747 } 706 748 707 - private addMeetingTime() { 708 - this.meetingTimes = [...this.meetingTimes, ""]; 709 - } 710 - 711 - private removeMeetingTime(index: number) { 712 - this.meetingTimes = this.meetingTimes.filter((_, i) => i !== index); 749 + private handleMeetingTimesChange(e: CustomEvent) { 750 + this.meetingTimes = e.detail; 713 751 } 714 752 715 - private updateMeetingTime(index: number, value: string) { 716 - this.meetingTimes = this.meetingTimes.map((time, i) => 717 - i === index ? value : time, 718 - ); 753 + private handleClassFieldInput(field: string, e: Event) { 754 + const value = (e.target as HTMLInputElement | HTMLSelectElement).value; 755 + this.editingClass = { ...this.editingClass, [field]: value }; 719 756 } 720 757 721 758 private cancelApproval() { 722 759 this.approvingEntry = null; 723 - this.meetingTimes = [""]; 760 + this.meetingTimes = []; 761 + this.editingClass = { 762 + courseCode: "", 763 + courseName: "", 764 + professor: "", 765 + semester: "", 766 + year: new Date().getFullYear(), 767 + }; 724 768 } 725 769 726 770 private async submitApproval() { 727 771 if (!this.approvingEntry) return; 728 772 729 - const entry = this.approvingEntry; 730 - const times = this.meetingTimes.filter((t) => t.trim() !== ""); 731 - 732 - if (times.length === 0) { 773 + if (this.meetingTimes.length === 0) { 733 774 this.error = "Please add at least one meeting time"; 734 775 return; 735 776 } 736 777 778 + // Convert MeetingTime objects to label strings 779 + const labels = this.meetingTimes.map((t) => t.label); 780 + 737 781 try { 738 782 const response = await fetch("/api/classes", { 739 783 method: "POST", 740 784 headers: { "Content-Type": "application/json" }, 741 785 body: JSON.stringify({ 742 - course_code: entry.course_code, 743 - name: entry.course_name, 744 - professor: entry.professor, 745 - semester: entry.semester, 746 - year: entry.year, 747 - meeting_times: times, 786 + course_code: this.editingClass.courseCode, 787 + name: this.editingClass.courseName, 788 + professor: this.editingClass.professor, 789 + semester: this.editingClass.semester, 790 + year: this.editingClass.year, 791 + meeting_times: labels, 748 792 }), 749 793 }); 750 794 751 795 if (!response.ok) { 752 - throw new Error("Failed to create class"); 796 + const data = await response.json(); 797 + console.error("Failed to create class:", data); 798 + throw new Error(data.error || "Failed to create class"); 753 799 } 754 800 755 - await fetch(`/api/admin/waitlist/${entry.id}`, { 801 + await fetch(`/api/admin/waitlist/${this.approvingEntry.id}`, { 756 802 method: "DELETE", 757 803 }); 758 804 ··· 760 806 761 807 this.activeTab = "classes"; 762 808 this.approvingEntry = null; 763 - this.meetingTimes = [""]; 764 - } catch { 765 - this.error = "Failed to approve waitlist entry. Please try again."; 809 + this.meetingTimes = []; 810 + this.editingClass = { 811 + courseCode: "", 812 + courseName: "", 813 + professor: "", 814 + semester: "", 815 + year: new Date().getFullYear(), 816 + }; 817 + } catch (error) { 818 + console.error("Error in submitApproval:", error); 819 + this.error = error instanceof Error ? error.message : "Failed to approve waitlist entry. Please try again."; 766 820 } 767 821 } 768 822 769 823 private renderApprovalModal() { 770 824 if (!this.approvingEntry) return ""; 771 825 772 - const entry = this.approvingEntry; 773 - 774 826 return html` 775 827 <div class="modal-overlay" @click=${this.cancelApproval}> 776 828 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 777 - <h2 class="modal-title">Add Meeting Times</h2> 829 + <h2 class="modal-title">Review & Create Class</h2> 778 830 779 831 <p style="margin-bottom: 1.5rem; color: var(--paynes-gray);"> 780 - Creating class: <strong>${entry.course_code} - ${entry.course_name}</strong> 832 + Review the class details and make any edits before creating 781 833 </p> 782 834 783 - <div class="form-group"> 784 - <label>Meeting Times</label> 785 - <div class="meeting-times-list"> 786 - ${this.meetingTimes.map( 787 - (time, index) => html` 788 - <div class="meeting-time-row"> 789 - <input 790 - type="text" 791 - placeholder="e.g., Monday Lecture, Wednesday Lab" 792 - .value=${time} 793 - @input=${(e: Event) => 794 - this.updateMeetingTime( 795 - index, 796 - (e.target as HTMLInputElement).value, 797 - )} 798 - /> 799 - ${ 800 - this.meetingTimes.length > 1 801 - ? html` 802 - <button 803 - class="btn-remove" 804 - @click=${() => this.removeMeetingTime(index)} 805 - > 806 - Remove 807 - </button> 808 - ` 809 - : "" 810 - } 811 - </div> 812 - `, 813 - )} 814 - <button class="btn-add" @click=${this.addMeetingTime}> 815 - + Add Meeting Time 816 - </button> 835 + ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 836 + 837 + <div class="form-grid"> 838 + <div class="form-group"> 839 + <label>Course Code *</label> 840 + <input 841 + type="text" 842 + required 843 + .value=${this.editingClass.courseCode} 844 + @input=${(e: Event) => this.handleClassFieldInput("courseCode", e)} 845 + /> 846 + </div> 847 + <div class="form-group"> 848 + <label>Course Name *</label> 849 + <input 850 + type="text" 851 + required 852 + .value=${this.editingClass.courseName} 853 + @input=${(e: Event) => this.handleClassFieldInput("courseName", e)} 854 + /> 855 + </div> 856 + <div class="form-group"> 857 + <label>Professor *</label> 858 + <input 859 + type="text" 860 + required 861 + .value=${this.editingClass.professor} 862 + @input=${(e: Event) => this.handleClassFieldInput("professor", e)} 863 + /> 864 + </div> 865 + <div class="form-group"> 866 + <label>Semester *</label> 867 + <select 868 + required 869 + .value=${this.editingClass.semester} 870 + @change=${(e: Event) => this.handleClassFieldInput("semester", e)} 871 + > 872 + <option value="">Select semester</option> 873 + <option value="Spring">Spring</option> 874 + <option value="Summer">Summer</option> 875 + <option value="Fall">Fall</option> 876 + <option value="Winter">Winter</option> 877 + </select> 878 + </div> 879 + <div class="form-group"> 880 + <label>Year *</label> 881 + <input 882 + type="number" 883 + required 884 + min="2020" 885 + max="2030" 886 + .value=${this.editingClass.year.toString()} 887 + @input=${(e: Event) => this.handleClassFieldInput("year", e)} 888 + /> 889 + </div> 890 + <div class="form-group form-group-full"> 891 + <label>Meeting Times *</label> 892 + <meeting-time-picker 893 + .value=${this.meetingTimes} 894 + @change=${this.handleMeetingTimesChange} 895 + ></meeting-time-picker> 817 896 </div> 818 897 </div> 819 898 ··· 824 903 <button 825 904 class="btn-submit" 826 905 @click=${this.submitApproval} 827 - ?disabled=${this.meetingTimes.every((t) => t.trim() === "")} 906 + ?disabled=${this.meetingTimes.length === 0} 828 907 > 829 908 Create Class 830 909 </button>
+10 -113
src/components/class-registration-modal.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 + import type { MeetingTime } from "./meeting-time-picker"; 4 + import "./meeting-time-picker"; 3 5 4 6 interface ClassResult { 5 7 id: string; ··· 27 29 semester: "", 28 30 year: new Date().getFullYear(), 29 31 additionalInfo: "", 30 - meetingTimes: [""], 32 + meetingTimes: [] as MeetingTime[], 31 33 }; 32 34 33 35 static override styles = css` ··· 389 391 padding: 2rem; 390 392 color: var(--paynes-gray); 391 393 } 392 - 393 - .meeting-times-list { 394 - display: flex; 395 - flex-direction: column; 396 - gap: 0.5rem; 397 - } 398 - 399 - .meeting-time-row { 400 - display: flex; 401 - gap: 0.5rem; 402 - align-items: center; 403 - } 404 - 405 - .meeting-time-row input { 406 - flex: 1; 407 - } 408 - 409 - .btn-remove { 410 - padding: 0.5rem 1rem; 411 - background: transparent; 412 - color: #dc2626; 413 - border: 2px solid #dc2626; 414 - border-radius: 6px; 415 - cursor: pointer; 416 - font-size: 0.875rem; 417 - font-weight: 500; 418 - transition: all 0.2s; 419 - font-family: inherit; 420 - } 421 - 422 - .btn-remove:hover { 423 - background: #dc2626; 424 - color: white; 425 - } 426 - 427 - .btn-add { 428 - margin-top: 0.5rem; 429 - padding: 0.5rem 1rem; 430 - background: transparent; 431 - color: var(--primary); 432 - border: 2px solid var(--primary); 433 - border-radius: 6px; 434 - cursor: pointer; 435 - font-size: 0.875rem; 436 - font-weight: 500; 437 - transition: all 0.2s; 438 - font-family: inherit; 439 - } 440 - 441 - .btn-add:hover { 442 - background: var(--primary); 443 - color: white; 444 - } 445 394 `; 446 395 447 396 private handleClose() { ··· 457 406 semester: "", 458 407 year: new Date().getFullYear(), 459 408 additionalInfo: "", 460 - meetingTimes: [""], 409 + meetingTimes: [], 461 410 }; 462 411 this.dispatchEvent(new CustomEvent("close")); 463 412 } ··· 566 515 this.showWaitlistForm = false; 567 516 } 568 517 569 - private addMeetingTime() { 518 + private handleMeetingTimesChange(e: CustomEvent) { 570 519 this.waitlistData = { 571 520 ...this.waitlistData, 572 - meetingTimes: [...this.waitlistData.meetingTimes, ""], 521 + meetingTimes: e.detail, 573 522 }; 574 - } 575 - 576 - private removeMeetingTime(index: number) { 577 - this.waitlistData = { 578 - ...this.waitlistData, 579 - meetingTimes: this.waitlistData.meetingTimes.filter( 580 - (_, i) => i !== index, 581 - ), 582 - }; 583 - } 584 - 585 - private updateMeetingTime(index: number, value: string) { 586 - const newTimes = [...this.waitlistData.meetingTimes]; 587 - newTimes[index] = value; 588 - this.waitlistData = { ...this.waitlistData, meetingTimes: newTimes }; 589 523 } 590 524 591 525 override render() { ··· 696 630 </div> 697 631 <div class="form-group form-group-full"> 698 632 <label>Meeting Times *</label> 699 - <div class="meeting-times-list"> 700 - ${this.waitlistData.meetingTimes.map( 701 - (time, index) => html` 702 - <div class="meeting-time-row"> 703 - <input 704 - type="text" 705 - required 706 - placeholder="e.g., Monday Lecture, Wednesday Lecture" 707 - .value=${time} 708 - @input=${(e: Event) => 709 - this.updateMeetingTime( 710 - index, 711 - (e.target as HTMLInputElement).value, 712 - )} 713 - @keydown=${(e: KeyboardEvent) => { 714 - if (e.key === "Enter") { 715 - e.preventDefault(); 716 - this.addMeetingTime(); 717 - } 718 - }} 719 - /> 720 - ${ 721 - this.waitlistData.meetingTimes.length > 1 722 - ? html` 723 - <button 724 - type="button" 725 - class="btn-remove" 726 - @click=${() => this.removeMeetingTime(index)} 727 - > 728 - Remove 729 - </button> 730 - ` 731 - : "" 732 - } 733 - </div> 734 - `, 735 - )} 736 - <button type="button" class="btn-add" @click=${this.addMeetingTime}> 737 - + Add Meeting Time 738 - </button> 739 - </div> 633 + <meeting-time-picker 634 + .value=${this.waitlistData.meetingTimes} 635 + @change=${this.handleMeetingTimesChange} 636 + ></meeting-time-picker> 740 637 </div> 741 638 <div class="form-group form-group-full"> 742 639 <label>Additional Info (optional)</label>
+150
src/components/meeting-time-picker.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + export interface MeetingTime { 5 + day: string; 6 + startTime: string; 7 + endTime: string; 8 + label: string; 9 + } 10 + 11 + interface DayState { 12 + day: string; 13 + shortName: string; 14 + selected: boolean; 15 + } 16 + 17 + @customElement("meeting-time-picker") 18 + export class MeetingTimePicker extends LitElement { 19 + @property({ type: Array }) value: MeetingTime[] = []; 20 + 21 + @state() private days: DayState[] = [ 22 + { day: "Monday", shortName: "Mon", selected: false }, 23 + { day: "Tuesday", shortName: "Tue", selected: false }, 24 + { day: "Wednesday", shortName: "Wed", selected: false }, 25 + { day: "Thursday", shortName: "Thu", selected: false }, 26 + { day: "Friday", shortName: "Fri", selected: false }, 27 + { day: "Saturday", shortName: "Sat", selected: false }, 28 + { day: "Sunday", shortName: "Sun", selected: false }, 29 + ]; 30 + 31 + static override styles = css` 32 + :host { 33 + display: block; 34 + } 35 + 36 + .day-selector { 37 + display: flex; 38 + gap: 0.5rem; 39 + flex-wrap: wrap; 40 + } 41 + 42 + .day-button { 43 + flex: 1; 44 + min-width: 3.5rem; 45 + padding: 0.75rem 0.5rem; 46 + background: var(--background); 47 + border: 2px solid var(--secondary); 48 + border-radius: 6px; 49 + font-size: 0.875rem; 50 + font-weight: 500; 51 + cursor: pointer; 52 + transition: all 0.2s; 53 + font-family: inherit; 54 + color: var(--text); 55 + } 56 + 57 + .day-button:hover { 58 + border-color: var(--primary); 59 + } 60 + 61 + .day-button.selected { 62 + background: var(--primary); 63 + border-color: var(--primary); 64 + color: white; 65 + } 66 + 67 + .helper-text { 68 + margin-top: 0.5rem; 69 + font-size: 0.75rem; 70 + color: var(--paynes-gray); 71 + } 72 + `; 73 + 74 + override connectedCallback() { 75 + super.connectedCallback(); 76 + this.loadFromValue(); 77 + } 78 + 79 + override updated(changedProperties: Map<string, unknown>) { 80 + if (changedProperties.has("value")) { 81 + this.loadFromValue(); 82 + } 83 + } 84 + 85 + private loadFromValue() { 86 + // Always reset all days first 87 + this.days = this.days.map((d) => ({ ...d, selected: false })); 88 + 89 + // If no value, we're done 90 + if (!this.value || this.value.length === 0) return; 91 + 92 + // Load from value 93 + for (const meeting of this.value) { 94 + const dayIndex = this.days.findIndex((d) => d.day === meeting.day); 95 + if (dayIndex !== -1) { 96 + this.days = this.days.map((d, i) => 97 + i === dayIndex ? { ...d, selected: true } : d, 98 + ); 99 + } 100 + } 101 + } 102 + 103 + private toggleDay(index: number) { 104 + this.days = this.days.map((d, i) => 105 + i === index ? { ...d, selected: !d.selected } : d, 106 + ); 107 + this.dispatchChange(); 108 + } 109 + 110 + private dispatchChange() { 111 + const selectedDays = this.days 112 + .filter((d) => d.selected) 113 + .map((d) => ({ 114 + day: d.day, 115 + startTime: "", 116 + endTime: "", 117 + label: d.day, 118 + })); 119 + 120 + this.dispatchEvent( 121 + new CustomEvent("change", { 122 + detail: selectedDays, 123 + bubbles: true, 124 + composed: true, 125 + }), 126 + ); 127 + } 128 + 129 + override render() { 130 + return html` 131 + <div class="day-selector"> 132 + ${this.days.map( 133 + (day, index) => html` 134 + <button 135 + type="button" 136 + class="day-button ${day.selected ? "selected" : ""}" 137 + @click=${() => this.toggleDay(index)} 138 + > 139 + ${day.shortName} 140 + </button> 141 + `, 142 + )} 143 + </div> 144 + 145 + <div class="helper-text"> 146 + Click on days to select when this class meets 147 + </div> 148 + `; 149 + } 150 + }
+1 -1
src/lib/classes.ts
··· 265 265 `SELECT * FROM classes 266 266 WHERE UPPER(course_code) LIKE UPPER(?) 267 267 AND archived = 0 268 - ORDER BY year DESC, semester DESC, professor ASC, section ASC`, 268 + ORDER BY year DESC, semester DESC, professor ASC`, 269 269 ) 270 270 .all(`%${courseCode}%`); 271 271 }