Skip to content

Commit be16927

Browse files
committed
perf: push all catalog filtering into sqlite, remove JS full-table load
The subscriptions editor was loading 20k+ all_courses rows into a JS array via chunked setTimeouts, then filtering in-memory getters that re-ran on every Alpine reactive tick. This froze the browser for 10+ seconds and caused stale state bugs when toggling terms. Now searchAllCourses / countAllCourses / getCoursesByIds / getAllCoursesDepts all query sqlite directly with WHERE clauses, so the JS heap never holds the full catalog. The middle column is capped at 200 rows with a "refine your search" hint. Cache arrays for the subscribed and single-run columns are explicitly refreshed when IDs change, decoupling them from the filter state.
1 parent 19b2aa0 commit be16927

3 files changed

Lines changed: 179 additions & 77 deletions

File tree

frontend/index.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ <h4 class="font-medium text-gray-800 text-sm mt-0.5" x-text="r.sub_title || 'Unt
481481
<span class="float-right ml-2"></span>
482482
</button>
483483
<div x-show="subsDeptOpen" class="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto min-w-[240px]">
484-
<input x-model="deptSearchQuery" @input="rebuildSubsFiltered()" type="text"
484+
<input x-model="deptSearchQuery" @input="onDeptSearchInput()" type="text"
485485
placeholder="搜索院系…"
486486
class="w-full border-b px-3 py-2 text-sm sticky top-0 bg-white focus:outline-none">
487487
<template x-for="d in subsDeptFiltered" :key="d">
@@ -555,7 +555,10 @@ <h3 class="text-sm font-semibold text-gray-700">已订阅</h3>
555555
<div class="flex-[2] min-w-0 flex flex-col">
556556
<div class="flex items-center justify-between px-2 py-1.5">
557557
<h3 class="text-sm font-semibold text-gray-700">搜索结果</h3>
558-
<span class="text-xs text-gray-400" x-text="subsFiltered.length + '门'"></span>
558+
<span class="text-xs text-gray-400"
559+
x-text="subsFilteredTotal > subsFiltered.length
560+
? (subsFiltered.length + '/' + subsFilteredTotal + '门')
561+
: (subsFiltered.length + '门')"></span>
559562
</div>
560563
<div class="bg-white border border-gray-200 rounded-lg overflow-y-auto max-h-[55vh] divide-y divide-gray-100 flex-1">
561564
<template x-for="c in subsFiltered" :key="c.course_id + '@' + c.term">
@@ -578,6 +581,11 @@ <h3 class="text-sm font-semibold text-gray-700">搜索结果</h3>
578581
</label>
579582
</template>
580583
<p x-show="subsFiltered.length === 0" class="text-center text-gray-400 text-xs py-8">没有匹配的课程</p>
584+
<p x-show="subsFilteredTotal > subsFiltered.length"
585+
class="text-center text-gray-400 text-xs py-3 bg-gray-50 border-t border-gray-100">
586+
仅显示前 <span x-text="subsFiltered.length"></span> 条(共
587+
<span x-text="subsFilteredTotal"></span> 条),请细化搜索条件。
588+
</p>
581589
</div>
582590
</div>
583591

frontend/js/app.js

Lines changed: 62 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,19 @@ document.addEventListener("alpine:init", () => {
252252
/* Subscriptions editor state — three-column layout:
253253
left (subscribed) | middle (catalog search) | right (single-run).
254254
The left column persists to GitHub Secret on demand; the right
255-
column is session-only and cleared after triggering a workflow. */
256-
allCourses: [], allCoursesTerms: [],
255+
column is session-only and cleared after triggering a workflow.
256+
Columns are driven by SQL queries against the in-memory shard DB
257+
so we never hold the full 20k-row catalog in JS. */
258+
allCoursesTerms: [],
257259
subsTerms: [], subsDepts: [], deptSearchQuery: "",
258260
subsSearchTitle: "", subsSearchTeacher: "",
259261
subsTermOpen: false, subsDeptOpen: false,
260262
subscribedIds: [], singleRunIds: [],
261263
subsSelLeft: [], subsSelMiddle: [], subsSelRight: [],
262-
subsFiltered: [],
264+
subsFiltered: [], subsFilteredTotal: 0,
265+
subsLimit: 200,
266+
_subsSubscribedCache: [], _subsSingleRunCache: [], _subsDeptCache: [],
267+
_subsFilterTimer: null, _deptFilterTimer: null,
263268
subsSaving: false, subsError: "",
264269
singleRunTriggering: false,
265270
/* Per-browser pinned-courses set, lazily synced to localStorage. */
@@ -613,13 +618,9 @@ document.addEventListener("alpine:init", () => {
613618

614619
// ── Subscriptions editor (three-column) ──────────────────────────
615620
openSubscriptions() {
616-
// Debug: log entry
617-
console.log("openSubscriptions called, view=", this.view);
618-
// Enter the page FIRST — before any DB work.
619621
try {
620622
this._go("subscriptions");
621-
console.log("_go returned, view now=", this.view);
622-
} catch(e) {
623+
} catch (e) {
623624
console.error("_go failed:", e);
624625
return;
625626
}
@@ -632,13 +633,17 @@ document.addEventListener("alpine:init", () => {
632633
this.subsSearchTeacher = "";
633634
this.subsTermOpen = false;
634635
this.subsDeptOpen = false;
635-
this.allCourses = [];
636636
this.subsFiltered = [];
637+
this.subsFilteredTotal = 0;
637638
this.singleRunIds = [];
638639
this.subsSelLeft = [];
639640
this.subsSelMiddle = [];
640641
this.subsSelRight = [];
642+
this._subsSubscribedCache = [];
643+
this._subsSingleRunCache = [];
644+
this._subsDeptCache = [];
641645
this.subsError = "";
646+
642647
// Load subscription (localStorage → meta table fallback)
643648
this.subscribedIds = [];
644649
try {
@@ -661,45 +666,22 @@ document.addEventListener("alpine:init", () => {
661666
} catch {}
662667
}
663668
}
664-
// Background-load the catalog after the page has rendered
665-
var self = this;
666-
setTimeout(function () { self._loadCoursesForTerms(); }, 200);
669+
this._refreshSubscribedCache();
670+
this._refreshSingleRunCache();
671+
this._refreshDeptCache();
672+
this.rebuildSubsFiltered();
667673
},
668-
// ── Async batch-load courses to avoid freezing the UI ───────────
669-
_loadCoursesForTerms() {
670-
var self = this;
671-
var terms = this.subsTerms.length
672-
? this.subsTerms
673-
: this.allCoursesTerms;
674-
675-
var tIdx = 0;
676-
var CHUNK = 200;
677-
var skipCount = 0;
678-
679-
function nextTerm() {
680-
if (tIdx >= terms.length) return;
681-
var rows = ICS.db.getAllCourses(terms[tIdx]);
682-
tIdx++;
683-
feed(0, rows);
684-
}
685-
686-
function feed(offset, rows) {
687-
var sub = rows.slice(offset, offset + CHUNK);
688-
self.allCourses = self.allCourses.concat(sub);
689-
// Only re-filter every 3 chunks (~600 rows) to reduce DOM churn
690-
skipCount++;
691-
if (skipCount % 3 === 0) {
692-
self.rebuildSubsFiltered();
693-
}
694-
if (offset + CHUNK >= rows.length) {
695-
self.rebuildSubsFiltered(); // Final flush
696-
setTimeout(nextTerm, 80);
697-
} else {
698-
setTimeout(function () { feed(offset + CHUNK, rows); }, 80);
699-
}
700-
}
701-
702-
setTimeout(nextTerm, 300); // Wait 300ms after page render
674+
// ── Cache refreshers (called explicitly when underlying IDs change) ─
675+
_refreshSubscribedCache() {
676+
this._subsSubscribedCache = ICS.db.getCoursesByIds(this.subscribedIds);
677+
},
678+
_refreshSingleRunCache() {
679+
this._subsSingleRunCache = ICS.db.getCoursesByIds(this.singleRunIds);
680+
},
681+
_refreshDeptCache() {
682+
this._subsDeptCache = ICS.db.getAllCoursesDepts(
683+
this.subsTerms, this.deptSearchQuery,
684+
);
703685
},
704686
// ── Term badge color (cyclic palette for the search results) ─────
705687
_TERM_COLORS: [
@@ -716,15 +698,9 @@ document.addEventListener("alpine:init", () => {
716698
for (var i = 0; i < term.length; i++) idx = (idx * 31 + term.charCodeAt(i)) | 0;
717699
return this._TERM_COLORS[Math.abs(idx) % this._TERM_COLORS.length];
718700
},
719-
// ── Column data ─────────────────────────────────────────────────
720-
get subscribedCourses() {
721-
var ids = new Set(this.subscribedIds.map(String));
722-
return this.allCourses.filter(function (c) { return ids.has(String(c.course_id)); });
723-
},
724-
get singleRunCourses() {
725-
var ids = new Set(this.singleRunIds.map(String));
726-
return this.allCourses.filter(function (c) { return ids.has(String(c.course_id)); });
727-
},
701+
// ── Column data (plain arrays kept in sync by cache refreshers) ─
702+
get subscribedCourses() { return this._subsSubscribedCache; },
703+
get singleRunCourses() { return this._subsSingleRunCache; },
728704
// ── Dropdown labels ─────────────────────────────────────────────
729705
get subsTermLabel() {
730706
if (!this.subsTerms.length) return '全部学期';
@@ -734,39 +710,46 @@ document.addEventListener("alpine:init", () => {
734710
if (!this.subsDepts.length) return '全部院系';
735711
return this.subsDepts.length + '个院系';
736712
},
737-
get subsDeptFiltered() {
738-
var q = (this.deptSearchQuery || '').toLowerCase();
739-
var deptSet = new Set();
740-
for (var i = 0; i < this.allCourses.length; i++) {
741-
var d = this.allCourses[i].dept;
742-
if (d && (!q || d.toLowerCase().indexOf(q) !== -1)) deptSet.add(d);
743-
}
744-
return Array.from(deptSet).sort();
713+
get subsDeptFiltered() { return this._subsDeptCache; },
714+
onDeptSearchInput() {
715+
// Debounced — typing in the dept search box requeries distinct depts
716+
// from sqlite filtered by term + substring.
717+
var self = this;
718+
clearTimeout(this._deptFilterTimer);
719+
this._deptFilterTimer = setTimeout(function () {
720+
self._refreshDeptCache();
721+
}, 150);
745722
},
746723
// ── Toggle multi-select ─────────────────────────────────────────
747724
toggleSubsTerm(term, checked) {
748725
var s = new Set(this.subsTerms);
749726
if (checked) s.add(term); else s.delete(term);
750727
this.subsTerms = Array.from(s);
751-
this._loadCoursesForTerms();
728+
// Term change invalidates both the dept dropdown (term-narrowed)
729+
// and the middle column (different rows match).
730+
this._refreshDeptCache();
731+
this.rebuildSubsFiltered();
752732
},
753733
toggleSubsDept(dept, checked) {
754734
var s = new Set(this.subsDepts);
755735
if (checked) s.add(dept); else s.delete(dept);
756736
this.subsDepts = Array.from(s);
757737
this.rebuildSubsFiltered();
758738
},
759-
// ── Filter middle column ─────────────────────────────────────────
739+
// ── Filter middle column (debounced SQL query) ───────────────────
760740
rebuildSubsFiltered() {
761-
var deptSet = new Set(this.subsDepts.map(function (d) { return d.toLowerCase(); }));
762-
var titleQ = (this.subsSearchTitle || "").trim().toLowerCase();
763-
var teacherQ = (this.subsSearchTeacher || "").trim().toLowerCase();
764-
this.subsFiltered = this.allCourses.filter(function (c) {
765-
if (deptSet.size && (!c.dept || !deptSet.has(c.dept.toLowerCase()))) return false;
766-
if (titleQ && (!c.title || c.title.toLowerCase().indexOf(titleQ) === -1)) return false;
767-
if (teacherQ && (!c.teacher || c.teacher.toLowerCase().indexOf(teacherQ) === -1)) return false;
768-
return true;
769-
});
741+
var self = this;
742+
clearTimeout(this._subsFilterTimer);
743+
this._subsFilterTimer = setTimeout(function () {
744+
var filters = {
745+
terms: self.subsTerms,
746+
depts: self.subsDepts,
747+
title: self.subsSearchTitle,
748+
teacher: self.subsSearchTeacher,
749+
};
750+
self.subsFiltered = ICS.db.searchAllCourses(filters, self.subsLimit);
751+
self.subsFilteredTotal = ICS.db.countAllCourses(filters);
752+
}, 150);
770753
},
771754
// ── Per-column multi-select ──────────────────────────────────────
772755
_toggleSel(arr, id, checked) {
@@ -791,27 +774,31 @@ document.addEventListener("alpine:init", () => {
791774
for (var i = 0; i < selected.length; i++) target.add(selected[i]);
792775
this.subscribedIds = Array.from(target);
793776
this.subsSelMiddle = [];
777+
this._refreshSubscribedCache();
794778
},
795779
moveFromSubscribed() {
796780
var target = new Set(this.subscribedIds.map(String));
797781
var selected = this.subsSelLeft;
798782
for (var i = 0; i < selected.length; i++) target.delete(selected[i]);
799783
this.subscribedIds = Array.from(target);
800784
this.subsSelLeft = [];
785+
this._refreshSubscribedCache();
801786
},
802787
moveToSingleRun() {
803788
var target = new Set(this.singleRunIds.map(String));
804789
var selected = this.subsSelMiddle;
805790
for (var i = 0; i < selected.length; i++) target.add(selected[i]);
806791
this.singleRunIds = Array.from(target);
807792
this.subsSelMiddle = [];
793+
this._refreshSingleRunCache();
808794
},
809795
moveFromSingleRun() {
810796
var target = new Set(this.singleRunIds.map(String));
811797
var selected = this.subsSelRight;
812798
for (var i = 0; i < selected.length; i++) target.delete(selected[i]);
813799
this.singleRunIds = Array.from(target);
814800
this.subsSelRight = [];
801+
this._refreshSingleRunCache();
815802
},
816803
// ── Save left column to GitHub Secret ────────────────────────────
817804
async saveSubscriptions() {

frontend/js/db.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,109 @@ function _getAllCoursesTerms() {
209209
).map((r) => r.term);
210210
}
211211

212+
function _buildCatalogWhere(filters) {
213+
// Shared WHERE/params builder for paged search + count + dept distinct.
214+
// Filters: { terms: string[], depts: string[], title: string, teacher: string }
215+
var clauses = [];
216+
var params = [];
217+
if (filters.terms && filters.terms.length) {
218+
clauses.push("term IN (" + filters.terms.map(function () { return "?"; }).join(",") + ")");
219+
for (var i = 0; i < filters.terms.length; i++) params.push(filters.terms[i]);
220+
}
221+
if (filters.depts && filters.depts.length) {
222+
clauses.push("dept IN (" + filters.depts.map(function () { return "?"; }).join(",") + ")");
223+
for (var j = 0; j < filters.depts.length; j++) params.push(filters.depts[j]);
224+
}
225+
if (filters.title && filters.title.trim()) {
226+
clauses.push("title LIKE ?");
227+
params.push("%" + filters.title.trim() + "%");
228+
}
229+
if (filters.teacher && filters.teacher.trim()) {
230+
clauses.push("teacher LIKE ?");
231+
params.push("%" + filters.teacher.trim() + "%");
232+
}
233+
return {
234+
where: clauses.length ? "WHERE " + clauses.join(" AND ") : "",
235+
params: params,
236+
};
237+
}
238+
239+
function _searchAllCourses(filters, limit) {
240+
// Paged catalog search — used by the subscriptions editor middle column.
241+
// Pushes all filtering into sqlite so the JS heap never holds the full
242+
// 20k-row catalog.
243+
var w = _buildCatalogWhere(filters || {});
244+
var sql = "SELECT course_id, term, title, teacher, dept FROM all_courses "
245+
+ w.where + " ORDER BY term DESC, title LIMIT ?";
246+
var p = w.params.slice();
247+
p.push(limit || 200);
248+
return _queryAll(sql, p);
249+
}
250+
251+
function _countAllCourses(filters) {
252+
var w = _buildCatalogWhere(filters || {});
253+
var rows = _queryAll("SELECT COUNT(*) AS n FROM all_courses " + w.where, w.params);
254+
return rows.length ? rows[0].n : 0;
255+
}
256+
257+
function _getCoursesByIds(ids) {
258+
// Look up the catalog rows for an arbitrary set of course_ids. Used by
259+
// the left ("已订阅") and right ("单次运行") columns so they can render
260+
// without holding the full catalog in JS. Deduplicates by course_id;
261+
// a course offered in multiple terms is shown with its most recent term.
262+
if (!ids || !ids.length) return [];
263+
var placeholders = ids.map(function () { return "?"; }).join(",");
264+
// Pull every term row that matches, then collapse to one per course_id
265+
// (preferring the most recent term) in JS — keeps the SQL simple.
266+
var rows = _queryAll(
267+
"SELECT course_id, term, title, teacher, dept FROM all_courses "
268+
+ "WHERE course_id IN (" + placeholders + ") ORDER BY term DESC",
269+
ids.map(String),
270+
);
271+
var seen = {};
272+
var out = [];
273+
for (var i = 0; i < rows.length; i++) {
274+
var cid = String(rows[i].course_id);
275+
if (seen[cid]) continue;
276+
seen[cid] = true;
277+
out.push(rows[i]);
278+
}
279+
// Synthesize empty placeholders for IDs the catalog doesn't know about,
280+
// so the count shown to the user matches what's actually in their list.
281+
var foundIds = {};
282+
for (var k = 0; k < out.length; k++) foundIds[String(out[k].course_id)] = true;
283+
for (var m = 0; m < ids.length; m++) {
284+
var idStr = String(ids[m]);
285+
if (!foundIds[idStr]) {
286+
out.push({ course_id: idStr, term: "", title: "", teacher: "", dept: "" });
287+
foundIds[idStr] = true;
288+
}
289+
}
290+
return out;
291+
}
292+
293+
function _getAllCoursesDepts(termFilter, search) {
294+
// Distinct dept list, optionally narrowed to a set of terms and a
295+
// case-insensitive substring search. Lets the dept dropdown stay
296+
// responsive without iterating the JS catalog.
297+
var clauses = ["dept IS NOT NULL", "dept != ''"];
298+
var params = [];
299+
if (termFilter && termFilter.length) {
300+
clauses.push("term IN (" + termFilter.map(function () { return "?"; }).join(",") + ")");
301+
for (var i = 0; i < termFilter.length; i++) params.push(termFilter[i]);
302+
}
303+
if (search && search.trim()) {
304+
clauses.push("LOWER(dept) LIKE ?");
305+
params.push("%" + search.trim().toLowerCase() + "%");
306+
}
307+
var rows = _queryAll(
308+
"SELECT DISTINCT dept FROM all_courses WHERE "
309+
+ clauses.join(" AND ") + " ORDER BY dept",
310+
params,
311+
);
312+
return rows.map(function (r) { return r.dept; });
313+
}
314+
212315
function _getSubscribedCourseIds() {
213316
// The ``courses`` table holds courses we've actually run. This is our
214317
// best signal of "currently subscribed" without reading the
@@ -233,6 +336,10 @@ window.ICS.db = {
233336
searchSummaries: _searchSummaries,
234337
getAllCourses: _getAllCourses,
235338
getAllCoursesTerms: _getAllCoursesTerms,
339+
searchAllCourses: _searchAllCourses,
340+
countAllCourses: _countAllCourses,
341+
getCoursesByIds: _getCoursesByIds,
342+
getAllCoursesDepts: _getAllCoursesDepts,
236343
getSubscribedCourseIds: _getSubscribedCourseIds,
237344
getMeta: _getMeta,
238345
};

0 commit comments

Comments
 (0)