Codex commited on
Commit
cc14448
·
1 Parent(s): 8c3a0fd

Fix planner conflicts and compact layout

Browse files
Files changed (3) hide show
  1. app.py +53 -0
  2. static/planner.js +51 -9
  3. static/v020.css +47 -10
app.py CHANGED
@@ -59,6 +59,7 @@ MSG_INVALID_WEEKDAY = "星期设置不正确"
59
  MSG_INVALID_WEEK_RANGE = "开始周数不能晚于结束周数"
60
  MSG_INVALID_WEEK_PATTERN = "单双周设置不正确"
61
  MSG_INVALID_DURATION = "默认任务时长需在 30 到 240 分钟之间"
 
62
  WEEKDAYS = [
63
  "星期一",
64
  "星期二",
@@ -284,6 +285,49 @@ def course_occurs_on(course: dict, current_week: int, selected_date: date) -> bo
284
  return True
285
 
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  def build_planner_payload(selected_date: date) -> dict:
288
  settings = store.get_schedule_settings()
289
  semester_start = date.fromisoformat(settings["semester_start"])
@@ -612,6 +656,15 @@ def update_task_schedule(task_id: str):
612
  except ValueError as exc:
613
  return jsonify({"ok": False, "error": str(exc)}), 400
614
 
 
 
 
 
 
 
 
 
 
615
  try:
616
  task = store.schedule_task(task_id, schedule)
617
  except KeyError:
 
59
  MSG_INVALID_WEEK_RANGE = "开始周数不能晚于结束周数"
60
  MSG_INVALID_WEEK_PATTERN = "单双周设置不正确"
61
  MSG_INVALID_DURATION = "默认任务时长需在 30 到 240 分钟之间"
62
+ MSG_SCHEDULE_CONFLICT = "该时间段已有课程或任务,请换一个时间"
63
  WEEKDAYS = [
64
  "星期一",
65
  "星期二",
 
285
  return True
286
 
287
 
288
+ def schedules_overlap(
289
+ left_start_minutes: int,
290
+ left_end_minutes: int,
291
+ right_start_minutes: int,
292
+ right_end_minutes: int,
293
+ ) -> bool:
294
+ return left_start_minutes < right_end_minutes and right_start_minutes < left_end_minutes
295
+
296
+
297
+ def find_schedule_conflict(task_id: str, schedule: dict | None) -> str | None:
298
+ if not schedule:
299
+ return None
300
+
301
+ schedule_date = date.fromisoformat(schedule["date"])
302
+ start_minutes = parse_time_to_minutes(schedule["start_time"])
303
+ end_minutes = parse_time_to_minutes(schedule["end_time"])
304
+
305
+ settings = store.get_schedule_settings()
306
+ semester_start = date.fromisoformat(settings["semester_start"])
307
+ current_week = get_academic_week(schedule_date, semester_start)
308
+
309
+ for task in store.list_tasks():
310
+ if task["id"] == task_id:
311
+ continue
312
+ existing_schedule = task.get("schedule")
313
+ if not existing_schedule or existing_schedule.get("date") != schedule["date"]:
314
+ continue
315
+ existing_start = parse_time_to_minutes(existing_schedule["start_time"])
316
+ existing_end = parse_time_to_minutes(existing_schedule["end_time"])
317
+ if schedules_overlap(start_minutes, end_minutes, existing_start, existing_end):
318
+ return task["title"]
319
+
320
+ for course in store.list_courses():
321
+ if not course_occurs_on(course, current_week, schedule_date):
322
+ continue
323
+ course_start = parse_time_to_minutes(course["start_time"])
324
+ course_end = parse_time_to_minutes(course["end_time"])
325
+ if schedules_overlap(start_minutes, end_minutes, course_start, course_end):
326
+ return course["title"]
327
+
328
+ return None
329
+
330
+
331
  def build_planner_payload(selected_date: date) -> dict:
332
  settings = store.get_schedule_settings()
333
  semester_start = date.fromisoformat(settings["semester_start"])
 
656
  except ValueError as exc:
657
  return jsonify({"ok": False, "error": str(exc)}), 400
658
 
659
+ conflict_title = find_schedule_conflict(task_id, schedule)
660
+ if conflict_title:
661
+ return jsonify(
662
+ {
663
+ "ok": False,
664
+ "error": f"该时间段与“{conflict_title}”冲突,请换一个时间",
665
+ }
666
+ ), 400
667
+
668
  try:
669
  task = store.schedule_task(task_id, schedule)
670
  except KeyError:
static/planner.js CHANGED
@@ -239,6 +239,28 @@
239
  return Math.max(MIN_DURATION, Number((state.planner.settings && state.planner.settings.default_task_duration_minutes) || 45));
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  function minutesToPixels(minutes) {
243
  return Math.round(minutes * getPixelsPerMinute());
244
  }
@@ -684,10 +706,8 @@
684
  block.style.setProperty("--event-accent", item.color);
685
  }
686
  const courseWeekText = getCourseWeekText(item);
687
- const coursePeriodText = getCoursePeriodRange(item.start_time, item.end_time);
688
  const courseLines = [
689
  courseWeekText,
690
- coursePeriodText,
691
  item.location || "",
692
  `${item.start_time} - ${item.end_time}`,
693
  ].filter(Boolean);
@@ -697,7 +717,6 @@
697
  <strong class="planner-course-title">${escapeHtml(item.title)}</strong>
698
  <div class="planner-course-details">
699
  <span class="planner-course-line">${escapeHtml(courseWeekText)}</span>
700
- <span class="planner-course-line">${escapeHtml(coursePeriodText)}</span>
701
  <span class="planner-course-line">${escapeHtml(item.location || "")}</span>
702
  <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
703
  </div>
@@ -793,6 +812,7 @@
793
  }
794
  event.preventDefault();
795
  preview.style.display = "none";
 
796
  if (!requireAuth()) {
797
  state.dragTaskId = null;
798
  return;
@@ -976,12 +996,10 @@
976
  block.style.setProperty("--event-accent", item.color);
977
  }
978
  const courseWeekText = getCourseWeekText(item);
979
- const coursePeriodText = getCoursePeriodRange(item.start_time, item.end_time);
980
  const courseLines = [
981
  courseWeekText,
982
- coursePeriodText,
983
- `${item.start_time} - ${item.end_time}`,
984
  item.location || "",
 
985
  ].filter(Boolean);
986
  block.title = [item.title, ...courseLines].join("\n");
987
  block.innerHTML = `
@@ -989,9 +1007,8 @@
989
  <strong class="planner-course-title">${escapeHtml(item.title)}</strong>
990
  <div class="planner-course-details">
991
  <span class="planner-course-line">${escapeHtml(courseWeekText)}</span>
992
- <span class="planner-course-line">${escapeHtml(coursePeriodText)}</span>
993
- <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
994
  <span class="planner-course-line">${escapeHtml(item.location || "")}</span>
 
995
  </div>
996
  </div>
997
  `;
@@ -1056,6 +1073,7 @@
1056
  endMinutes: item.endMinutes,
1057
  duration: item.endMinutes - item.startMinutes,
1058
  pointerOffsetMinutes: point ? point.minutes - item.startMinutes : 0,
 
1059
  };
1060
 
1061
  if (typeof block.setPointerCapture === "function") {
@@ -1099,18 +1117,21 @@
1099
  const duration = getTaskDuration(task);
1100
  const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
1101
  const dayMeta = getWeekDayMeta(point.date);
 
1102
  preview.style.display = "grid";
 
1103
  setBlockBounds(preview, startMinutes, startMinutes + duration, dayStart);
1104
  setBlockHorizontalBounds(preview, point.date, 0, 1);
1105
  preview.innerHTML = `
1106
  <strong>${escapeHtml(dayMeta ? dayMeta.short_label : "")}</strong>
1107
- <span>${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}</span>
1108
  `;
1109
  });
1110
 
1111
  canvasLayer.addEventListener("dragleave", (event) => {
1112
  if (!canvasLayer.contains(event.relatedTarget)) {
1113
  preview.style.display = "none";
 
1114
  }
1115
  });
1116
 
@@ -1135,6 +1156,11 @@
1135
  try {
1136
  const duration = getTaskDuration(task);
1137
  const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
 
 
 
 
 
1138
  await requestJSON(`/api/tasks/${task.id}/schedule`, {
1139
  method: "PATCH",
1140
  body: JSON.stringify({
@@ -1253,6 +1279,7 @@
1253
  const preview = document.getElementById("timelineDropPreview");
1254
  if (preview) {
1255
  preview.style.display = "none";
 
1256
  }
1257
  });
1258
 
@@ -1300,6 +1327,14 @@
1300
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
1301
  }
1302
 
 
 
 
 
 
 
 
 
1303
  updateEventLayout(state.interaction.block, {
1304
  date: state.interaction.currentDate,
1305
  startMinutes: state.interaction.startMinutes,
@@ -1363,6 +1398,7 @@
1363
 
1364
  const current = state.interaction;
1365
  current.block.classList.remove("is-dragging");
 
1366
  state.interaction = null;
1367
  releaseInteractionPointer(current);
1368
  finishPlannerInteraction();
@@ -1376,6 +1412,12 @@
1376
  return;
1377
  }
1378
 
 
 
 
 
 
 
1379
  persistWeekInteraction(current);
1380
  }
1381
 
 
239
  return Math.max(MIN_DURATION, Number((state.planner.settings && state.planner.settings.default_task_duration_minutes) || 45));
240
  }
241
 
242
+ function rangesOverlap(leftStart, leftEnd, rightStart, rightEnd) {
243
+ return leftStart < rightEnd && rightStart < leftEnd;
244
+ }
245
+
246
+ function hasScheduleConflict(dateIso, startMinutes, endMinutes, ignoreTaskId = null) {
247
+ return (state.planner.scheduled_items || []).some((item) => {
248
+ const itemDate = item.date || state.selectedDate;
249
+ if (itemDate !== dateIso) {
250
+ return false;
251
+ }
252
+ if (ignoreTaskId && item.kind === "task" && item.task_id === ignoreTaskId) {
253
+ return false;
254
+ }
255
+ return rangesOverlap(
256
+ startMinutes,
257
+ endMinutes,
258
+ toMinutes(item.start_time),
259
+ toMinutes(item.end_time)
260
+ );
261
+ });
262
+ }
263
+
264
  function minutesToPixels(minutes) {
265
  return Math.round(minutes * getPixelsPerMinute());
266
  }
 
706
  block.style.setProperty("--event-accent", item.color);
707
  }
708
  const courseWeekText = getCourseWeekText(item);
 
709
  const courseLines = [
710
  courseWeekText,
 
711
  item.location || "",
712
  `${item.start_time} - ${item.end_time}`,
713
  ].filter(Boolean);
 
717
  <strong class="planner-course-title">${escapeHtml(item.title)}</strong>
718
  <div class="planner-course-details">
719
  <span class="planner-course-line">${escapeHtml(courseWeekText)}</span>
 
720
  <span class="planner-course-line">${escapeHtml(item.location || "")}</span>
721
  <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
722
  </div>
 
812
  }
813
  event.preventDefault();
814
  preview.style.display = "none";
815
+ preview.classList.remove("is-conflict");
816
  if (!requireAuth()) {
817
  state.dragTaskId = null;
818
  return;
 
996
  block.style.setProperty("--event-accent", item.color);
997
  }
998
  const courseWeekText = getCourseWeekText(item);
 
999
  const courseLines = [
1000
  courseWeekText,
 
 
1001
  item.location || "",
1002
+ `${item.start_time} - ${item.end_time}`,
1003
  ].filter(Boolean);
1004
  block.title = [item.title, ...courseLines].join("\n");
1005
  block.innerHTML = `
 
1007
  <strong class="planner-course-title">${escapeHtml(item.title)}</strong>
1008
  <div class="planner-course-details">
1009
  <span class="planner-course-line">${escapeHtml(courseWeekText)}</span>
 
 
1010
  <span class="planner-course-line">${escapeHtml(item.location || "")}</span>
1011
+ <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
1012
  </div>
1013
  </div>
1014
  `;
 
1073
  endMinutes: item.endMinutes,
1074
  duration: item.endMinutes - item.startMinutes,
1075
  pointerOffsetMinutes: point ? point.minutes - item.startMinutes : 0,
1076
+ hasConflict: false,
1077
  };
1078
 
1079
  if (typeof block.setPointerCapture === "function") {
 
1117
  const duration = getTaskDuration(task);
1118
  const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
1119
  const dayMeta = getWeekDayMeta(point.date);
1120
+ const hasConflict = hasScheduleConflict(point.date, startMinutes, startMinutes + duration, task.id);
1121
  preview.style.display = "grid";
1122
+ preview.classList.toggle("is-conflict", hasConflict);
1123
  setBlockBounds(preview, startMinutes, startMinutes + duration, dayStart);
1124
  setBlockHorizontalBounds(preview, point.date, 0, 1);
1125
  preview.innerHTML = `
1126
  <strong>${escapeHtml(dayMeta ? dayMeta.short_label : "")}</strong>
1127
+ <span>${hasConflict ? "时间冲突" : `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`}</span>
1128
  `;
1129
  });
1130
 
1131
  canvasLayer.addEventListener("dragleave", (event) => {
1132
  if (!canvasLayer.contains(event.relatedTarget)) {
1133
  preview.style.display = "none";
1134
+ preview.classList.remove("is-conflict");
1135
  }
1136
  });
1137
 
 
1156
  try {
1157
  const duration = getTaskDuration(task);
1158
  const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
1159
+ if (hasScheduleConflict(point.date, startMinutes, startMinutes + duration, task.id)) {
1160
+ showToast("该时间段已有课程或任务,请换一个时间", "error");
1161
+ state.dragTaskId = null;
1162
+ return;
1163
+ }
1164
  await requestJSON(`/api/tasks/${task.id}/schedule`, {
1165
  method: "PATCH",
1166
  body: JSON.stringify({
 
1279
  const preview = document.getElementById("timelineDropPreview");
1280
  if (preview) {
1281
  preview.style.display = "none";
1282
+ preview.classList.remove("is-conflict");
1283
  }
1284
  });
1285
 
 
1327
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
1328
  }
1329
 
1330
+ state.interaction.hasConflict = hasScheduleConflict(
1331
+ state.interaction.currentDate,
1332
+ state.interaction.startMinutes,
1333
+ state.interaction.endMinutes,
1334
+ state.interaction.taskId
1335
+ );
1336
+ state.interaction.block.classList.toggle("is-conflict", !!state.interaction.hasConflict);
1337
+
1338
  updateEventLayout(state.interaction.block, {
1339
  date: state.interaction.currentDate,
1340
  startMinutes: state.interaction.startMinutes,
 
1398
 
1399
  const current = state.interaction;
1400
  current.block.classList.remove("is-dragging");
1401
+ current.block.classList.remove("is-conflict");
1402
  state.interaction = null;
1403
  releaseInteractionPointer(current);
1404
  finishPlannerInteraction();
 
1412
  return;
1413
  }
1414
 
1415
+ if (current.hasConflict) {
1416
+ loadPlanner(current.currentDate || state.selectedDate, true);
1417
+ showToast("该时间段已有课程或任务,请换一个时间", "error");
1418
+ return;
1419
+ }
1420
+
1421
  persistWeekInteraction(current);
1422
  }
1423
 
static/v020.css CHANGED
@@ -160,15 +160,19 @@ body.planner-interacting .page-planner {
160
  display: grid;
161
  gap: 12px;
162
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
 
163
  align-items: stretch;
164
  align-content: start;
165
  min-height: 0;
166
- height: auto;
 
167
  }
168
 
169
  .todo-column {
170
  padding: 16px;
171
  min-height: 0;
 
 
172
  display: grid;
173
  grid-template-rows: auto minmax(0, 1fr);
174
  }
@@ -185,13 +189,17 @@ body.planner-interacting .page-planner {
185
  gap: 10px;
186
  margin-top: 12px;
187
  min-height: 0;
188
- overflow: auto;
 
 
 
189
  padding-right: 4px;
190
  }
191
 
192
  .task-card {
193
  padding: 14px;
194
  border-radius: 18px;
 
195
  }
196
 
197
  .task-copy h4 {
@@ -219,7 +227,7 @@ body.planner-interacting .page-planner {
219
  height: 100%;
220
  display: grid;
221
  grid-template-rows: auto auto minmax(0, 1fr);
222
- gap: 10px;
223
  }
224
 
225
  .planner-head h2 {
@@ -295,12 +303,27 @@ body.planner-interacting .page-planner {
295
 
296
  .planner-layout {
297
  display: grid;
298
- grid-template-columns: minmax(0, 7.7fr) minmax(250px, 2.3fr);
299
  gap: 12px;
300
  min-height: 0;
301
  overflow: hidden;
302
  }
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  .timeline-surface,
305
  .planner-sidebar {
306
  border-radius: 26px;
@@ -591,8 +614,8 @@ body.planner-interacting .page-planner {
591
  .planner-course-title {
592
  display: block;
593
  margin: 0;
594
- font-size: 0.72rem;
595
- line-height: 1.08;
596
  letter-spacing: 0.01em;
597
  word-break: break-word;
598
  overflow: hidden;
@@ -605,8 +628,8 @@ body.planner-interacting .page-planner {
605
  display: grid;
606
  gap: 1px;
607
  color: rgba(238, 244, 251, 0.82);
608
- font-size: 0.56rem;
609
- line-height: 1.08;
610
  }
611
 
612
  .planner-course-line {
@@ -668,6 +691,17 @@ body.planner-interacting .page-planner {
668
  z-index: 8;
669
  }
670
 
 
 
 
 
 
 
 
 
 
 
 
671
  .course-event {
672
  padding: 7px 8px 7px 10px;
673
  }
@@ -749,7 +783,7 @@ body.planner-interacting .page-planner {
749
  gap: 12px;
750
  align-content: start;
751
  min-height: 0;
752
- grid-template-rows: auto auto minmax(0, 1fr);
753
  }
754
 
755
  .planner-sidebar-note {
@@ -763,7 +797,10 @@ body.planner-interacting .page-planner {
763
  display: grid;
764
  gap: 10px;
765
  min-height: 0;
766
- overflow: auto;
 
 
 
767
  padding-right: 4px;
768
  }
769
 
 
160
  display: grid;
161
  gap: 12px;
162
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
163
+ grid-auto-rows: minmax(0, 1fr);
164
  align-items: stretch;
165
  align-content: start;
166
  min-height: 0;
167
+ height: 100%;
168
+ overflow: hidden;
169
  }
170
 
171
  .todo-column {
172
  padding: 16px;
173
  min-height: 0;
174
+ height: 100%;
175
+ max-height: 100%;
176
  display: grid;
177
  grid-template-rows: auto minmax(0, 1fr);
178
  }
 
189
  gap: 10px;
190
  margin-top: 12px;
191
  min-height: 0;
192
+ height: 100%;
193
+ max-height: 100%;
194
+ overflow-y: auto;
195
+ overscroll-behavior: contain;
196
  padding-right: 4px;
197
  }
198
 
199
  .task-card {
200
  padding: 14px;
201
  border-radius: 18px;
202
+ flex: none;
203
  }
204
 
205
  .task-copy h4 {
 
227
  height: 100%;
228
  display: grid;
229
  grid-template-rows: auto auto minmax(0, 1fr);
230
+ gap: 8px;
231
  }
232
 
233
  .planner-head h2 {
 
303
 
304
  .planner-layout {
305
  display: grid;
306
+ grid-template-columns: minmax(0, 8.2fr) minmax(220px, 1.8fr);
307
  gap: 12px;
308
  min-height: 0;
309
  overflow: hidden;
310
  }
311
 
312
+ .page-planner .planner-head h2,
313
+ .page-planner #plannerHeadlineNote,
314
+ .page-planner .planner-sidebar-note,
315
+ .page-planner .planner-sidebar-head .column-label {
316
+ display: none;
317
+ }
318
+
319
+ .page-planner .planner-head {
320
+ align-items: center;
321
+ }
322
+
323
+ .page-planner .planner-head .section-kicker {
324
+ margin: 0;
325
+ }
326
+
327
  .timeline-surface,
328
  .planner-sidebar {
329
  border-radius: 26px;
 
614
  .planner-course-title {
615
  display: block;
616
  margin: 0;
617
+ font-size: 0.74rem;
618
+ line-height: 1.12;
619
  letter-spacing: 0.01em;
620
  word-break: break-word;
621
  overflow: hidden;
 
628
  display: grid;
629
  gap: 1px;
630
  color: rgba(238, 244, 251, 0.82);
631
+ font-size: 0.58rem;
632
+ line-height: 1.12;
633
  }
634
 
635
  .planner-course-line {
 
691
  z-index: 8;
692
  }
693
 
694
+ .planner-event.is-conflict,
695
+ .timeline-drop-preview.is-conflict {
696
+ border-color: rgba(255, 107, 92, 0.72);
697
+ box-shadow: 0 0 0 1px rgba(255, 107, 92, 0.22), 0 16px 28px rgba(0, 0, 0, 0.24);
698
+ }
699
+
700
+ .planner-event.is-conflict::before,
701
+ .timeline-drop-preview.is-conflict::before {
702
+ background: #ff6b5c;
703
+ }
704
+
705
  .course-event {
706
  padding: 7px 8px 7px 10px;
707
  }
 
783
  gap: 12px;
784
  align-content: start;
785
  min-height: 0;
786
+ grid-template-rows: auto minmax(0, 1fr);
787
  }
788
 
789
  .planner-sidebar-note {
 
797
  display: grid;
798
  gap: 10px;
799
  min-height: 0;
800
+ height: 100%;
801
+ max-height: 100%;
802
+ overflow-y: auto;
803
+ overscroll-behavior: contain;
804
  padding-right: 4px;
805
  }
806