🪻 distributed transcription service thistle.dunkirk.sh

Compare changes

Choose any two refs to compare.

+17 -3
src/components/class-view.ts
···
<!-- Pending Recordings for Voting -->
${
this.meetingTimes.map((meeting) => {
-
const pendingCount = this.transcriptions.filter(
-
(t) => t.meeting_time_id === meeting.id && t.status === "pending",
-
).length;
+
// Apply section filtering to pending recordings
+
const sectionFilter = this.selectedSectionFilter || this.userSection;
+
+
const pendingCount = this.transcriptions.filter((t) => {
+
if (t.meeting_time_id !== meeting.id || t.status !== "pending") {
+
return false;
+
}
+
+
// Filter by section if applicable
+
if (this.sections.length > 0 && sectionFilter) {
+
// Show recordings from user's section or no section (unassigned)
+
return t.section_id === sectionFilter || t.section_id === null;
+
}
+
+
return true;
+
}).length;
// Only show if there are pending recordings
if (pendingCount === 0) return "";
···
.classId=${this.classId}
.meetingTimeId=${meeting.id}
.meetingTimeLabel=${meeting.label}
+
.sectionId=${sectionFilter}
></pending-recordings-view>
</div>
`;
+9 -1
src/components/pending-recordings-view.ts
···
@property({ type: String }) classId = "";
@property({ type: String }) meetingTimeId = "";
@property({ type: String }) meetingTimeLabel = "";
+
@property({ type: String }) sectionId: string | null = null;
@state() private recordings: PendingRecording[] = [];
@state() private userVote: string | null = null;
···
this.loadingInProgress = true;
try {
-
const response = await fetch(
+
// Build URL with optional section_id parameter
+
const url = new URL(
`/api/classes/${this.classId}/meetings/${this.meetingTimeId}/recordings`,
+
window.location.origin,
);
+
if (this.sectionId !== null) {
+
url.searchParams.set("section_id", this.sectionId);
+
}
+
+
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error("Failed to load recordings");
+72 -22
src/components/upload-recording-modal.ts
···
@state() private uploadComplete = false;
@state() private uploadedTranscriptionId: string | null = null;
@state() private submitting = false;
+
@state() private selectedDate: string = "";
static override styles = css`
:host {
···
.meeting-time-selector {
display: flex;
-
flex-direction: column;
gap: 0.5rem;
}
···
this.uploadComplete = false;
this.uploadedTranscriptionId = null;
this.submitting = false;
+
this.selectedDate = "";
if (this.selectedFile && this.classId) {
+
// Set initial date from file
+
const fileDate = new Date(this.selectedFile.lastModified);
+
this.selectedDate = fileDate.toISOString().split("T")[0] || "";
// Start both detection and upload in parallel
this.detectMeetingTime();
this.startBackgroundUpload();
···
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
// Use user's section by default, or allow override
-
const sectionToUse = this.selectedSectionId || this.userSection;
-
if (sectionToUse) {
-
formData.append("section_id", sectionToUse);
+
// Send recording date (from date picker or file timestamp)
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
formData.append("recording_date", Math.floor(date.getTime() / 1000).toString());
+
} else if (this.selectedFile.lastModified) {
+
// Use file's lastModified as recording date
+
formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString());
}
+
+
// Don't send section_id yet - will be set via PATCH when user confirms
const xhr = new XMLHttpRequest();
···
}
private async detectMeetingTime() {
-
if (!this.selectedFile || !this.classId) return;
+
if (!this.classId) return;
this.detectingMeetingTime = true;
try {
const formData = new FormData();
-
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
// Send the file's original lastModified timestamp (preserved by browser)
-
// This is more accurate than server-side file timestamps
-
if (this.selectedFile.lastModified) {
-
formData.append(
-
"file_timestamp",
-
this.selectedFile.lastModified.toString(),
-
);
+
// Use selected date or file's lastModified timestamp
+
let timestamp: number;
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time to avoid timezone issues)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
timestamp = date.getTime();
+
} else if (this.selectedFile?.lastModified) {
+
timestamp = this.selectedFile.lastModified;
+
} else {
+
return;
}
+
+
formData.append("file_timestamp", timestamp.toString());
const response = await fetch("/api/transcriptions/detect-meeting-time", {
method: "POST",
···
this.selectedMeetingTimeId = meetingTimeId;
}
+
private handleDateChange(e: Event) {
+
const input = e.target as HTMLInputElement;
+
this.selectedDate = input.value;
+
// Re-detect meeting time when date changes
+
if (this.selectedDate && this.classId) {
+
this.detectMeetingTime();
+
}
+
}
+
private handleSectionChange(e: Event) {
const select = e.target as HTMLSelectElement;
this.selectedSectionId = select.value || null;
···
this.error = null;
try {
+
// Get section to use (selected override or user's section)
+
const sectionToUse = this.selectedSectionId || this.userSection;
+
const response = await fetch(
`/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`,
{
···
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meeting_time_id: this.selectedMeetingTimeId,
+
section_id: sectionToUse,
}),
},
);
···
this.uploadProgress = 0;
this.uploadedTranscriptionId = null;
this.submitting = false;
+
this.selectedDate = "";
this.dispatchEvent(new CustomEvent("close"));
}
···
${
this.selectedFile
? html`
+
<div class="form-group">
+
<label for="date">Recording Date</label>
+
<input
+
type="date"
+
id="date"
+
.value=${this.selectedDate}
+
@change=${this.handleDateChange}
+
?disabled=${this.uploading}
+
style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"
+
/>
+
<div class="help-text">
+
Change the date to detect the correct meeting time
+
</div>
+
</div>
+
<div class="form-group">
<label>Meeting Time</label>
${
···
}
${
-
this.sections.length > 1 && this.selectedFile
+
this.sections.length > 0 && this.selectedFile
? html`
<div class="form-group">
-
<label for="section">Section (optional)</label>
+
<label for="section">Section</label>
<select
id="section"
@change=${this.handleSectionChange}
?disabled=${this.uploading}
+
.value=${this.selectedSectionId || this.userSection || ""}
>
<option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option>
-
${this.sections.map(
-
(section) => html`
+
${this.sections
+
.filter((section) => section.id !== this.userSection)
+
.map(
+
(section) => html`
<option value=${section.id}>${section.section_number}</option>
`,
-
)}
+
)}
</select>
<div class="help-text">
-
Override which section this recording is for
+
Select which section this recording is for (defaults to your section)
</div>
</div>
`
···
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
Cancel
</button>
-
${this.uploadComplete && this.selectedMeetingTimeId ? html`
+
${
+
this.uploadComplete && this.selectedMeetingTimeId
+
? html`
<button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}>
${this.submitting ? "Submitting..." : "Confirm & Submit"}
</button>
-
` : ""}
+
`
+
: ""
+
}
</div>
</div>
</div>
+15
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id);
`,
},
+
{
+
version: 4,
+
name: "Add recording_date to transcriptions for chronological ordering",
+
sql: `
+
-- Add recording_date (timestamp when the recording was made, not uploaded)
+
-- Defaults to created_at for existing records
+
ALTER TABLE transcriptions ADD COLUMN recording_date INTEGER;
+
+
-- Set recording_date to created_at for existing records
+
UPDATE transcriptions SET recording_date = created_at WHERE recording_date IS NULL;
+
+
-- Create index for ordering by recording date
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_recording_date ON transcriptions(recording_date);
+
`,
+
},
];
function getCurrentVersion(): number {
+34 -11
src/index.ts
···
const body = await req.json();
const meetingTimeId = body.meeting_time_id;
+
const sectionId = body.section_id;
if (!meetingTimeId) {
return Response.json(
···
-
// Update meeting time
-
db.run(
-
"UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
-
[meetingTimeId, transcriptionId],
-
);
+
// Update meeting time and optionally section_id
+
if (sectionId !== undefined) {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ?, section_id = ? WHERE id = ?",
+
[meetingTimeId, sectionId, transcriptionId],
+
);
+
} else {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
+
[meetingTimeId, transcriptionId],
+
);
+
}
return Response.json({
success: true,
···
);
-
// Get user's section for filtering (admins see all)
-
const userSection =
-
user.role === "admin" ? null : getUserSection(user.id, classId);
+
// Get section filter from query params or use user's section
+
const url = new URL(req.url);
+
const sectionParam = url.searchParams.get("section_id");
+
const sectionFilter =
+
sectionParam !== null
+
? sectionParam || null // empty string becomes null
+
: user.role === "admin"
+
? null
+
: getUserSection(user.id, classId);
const recordings = getPendingRecordings(
classId,
meetingTimeId,
-
userSection,
+
sectionFilter,
);
const totalUsers = getEnrolledUserCount(classId);
const userVote = getUserVoteForMeeting(
···
const winningId = checkAutoSubmit(
classId,
meetingTimeId,
-
userSection,
+
sectionFilter,
);
return Response.json({
···
const file = formData.get("audio") as File;
const classId = formData.get("class_id") as string | null;
const sectionId = formData.get("section_id") as string | null;
+
const recordingDateStr = formData.get("recording_date") as
+
| string
+
| null;
if (!file) throw ValidationErrors.missingField("audio");
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
+
// Parse recording date (default to current time if not provided)
+
const recordingDate = recordingDateStr
+
? Number.parseInt(recordingDateStr, 10)
+
: Math.floor(Date.now() / 1000);
+
// Create database record (without meeting_time_id - will be set later via PATCH)
db.run(
-
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
transcriptionId,
user.id,
···
filename,
file.name,
"pending",
+
recordingDate,
],
);
+1 -1
src/lib/classes.ts
···
`SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at
FROM transcriptions
WHERE class_id = ?
-
ORDER BY created_at DESC`,
+
ORDER BY recording_date DESC, created_at DESC`,
)
.all(classId);
}