Codex commited on
Commit
0855e12
·
1 Parent(s): 3b5b6d8

Release v0.2.0 weekly planner polish

Browse files
Files changed (5) hide show
  1. app.py +110 -3
  2. static/app.js +2 -6
  3. static/planner.js +541 -43
  4. static/v020.css +332 -72
  5. templates/index.html +15 -15
app.py CHANGED
@@ -1,6 +1,6 @@
1
  import hmac
2
  import os
3
- from datetime import date, datetime
4
  from pathlib import Path
5
  from urllib.parse import urlparse
6
  from zoneinfo import ZoneInfo
@@ -68,6 +68,7 @@ WEEKDAYS = [
68
  "星期六",
69
  "星期日",
70
  ]
 
71
 
72
 
73
  def get_password() -> str:
@@ -260,6 +261,15 @@ def parse_task_schedule_payload(payload: dict, settings: dict) -> dict | None:
260
  }
261
 
262
 
 
 
 
 
 
 
 
 
 
263
  def course_occurs_on(course: dict, current_week: int, selected_date: date) -> bool:
264
  if current_week < 1:
265
  return False
@@ -341,6 +351,103 @@ def build_planner_payload(selected_date: date) -> dict:
341
  }
342
 
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  @app.context_processor
345
  def inject_globals():
346
  return {
@@ -352,7 +459,7 @@ def inject_globals():
352
  def index():
353
  login_required = request.args.get("login") == "required"
354
  next_path = safe_next_path(request.args.get("next"))
355
- planner_payload = build_planner_payload(beijing_now().date())
356
  return render_template(
357
  "index.html",
358
  categories=store.list_categories(),
@@ -381,7 +488,7 @@ def planner():
381
  selected_date = parse_iso_date(request.args.get("date"))
382
  except ValueError as exc:
383
  return jsonify({"ok": False, "error": str(exc)}), 400
384
- return jsonify({"ok": True, "planner": build_planner_payload(selected_date)})
385
 
386
 
387
  @app.post("/api/login")
 
1
  import hmac
2
  import os
3
+ from datetime import date, datetime, timedelta
4
  from pathlib import Path
5
  from urllib.parse import urlparse
6
  from zoneinfo import ZoneInfo
 
68
  "星期六",
69
  "星期日",
70
  ]
71
+ WEEKDAY_SHORT = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
72
 
73
 
74
  def get_password() -> str:
 
261
  }
262
 
263
 
264
+ def get_week_start(target_date: date) -> date:
265
+ return target_date - timedelta(days=target_date.isoweekday() - 1)
266
+
267
+
268
+ def get_academic_week(target_date: date, semester_start: date) -> int:
269
+ delta_days = (target_date - semester_start).days
270
+ return (delta_days // 7) + 1 if delta_days >= 0 else 0
271
+
272
+
273
  def course_occurs_on(course: dict, current_week: int, selected_date: date) -> bool:
274
  if current_week < 1:
275
  return False
 
351
  }
352
 
353
 
354
+ def build_week_planner_payload(selected_date: date) -> dict:
355
+ settings = store.get_schedule_settings()
356
+ semester_start = date.fromisoformat(settings["semester_start"])
357
+ week_start = get_week_start(selected_date)
358
+ week_end = week_start + timedelta(days=6)
359
+ academic_week = get_academic_week(selected_date, semester_start)
360
+ task_list = [serialize_task(task) for task in store.list_tasks()]
361
+
362
+ week_days = []
363
+ week_day_set = set()
364
+ today = beijing_now().date()
365
+ for offset in range(7):
366
+ current_day = week_start + timedelta(days=offset)
367
+ iso_day = current_day.isoformat()
368
+ week_day_set.add(iso_day)
369
+ week_days.append(
370
+ {
371
+ "iso": iso_day,
372
+ "label": WEEKDAYS[current_day.weekday()],
373
+ "short_label": WEEKDAY_SHORT[current_day.weekday()],
374
+ "month_day": current_day.strftime("%m/%d"),
375
+ "day_of_month": current_day.day,
376
+ "is_today": current_day == today,
377
+ }
378
+ )
379
+
380
+ scheduled_items: list[dict] = []
381
+ for task in task_list:
382
+ schedule = task.get("schedule")
383
+ if schedule and schedule.get("date") in week_day_set:
384
+ scheduled_items.append(
385
+ {
386
+ "id": f"planner_{task['id']}",
387
+ "kind": "task",
388
+ "task_id": task["id"],
389
+ "title": task["title"],
390
+ "category_name": task["category_name"],
391
+ "completed": task.get("completed", False),
392
+ "due_at": task["due_at"],
393
+ "progress_percent": task["progress_percent"],
394
+ "date": schedule["date"],
395
+ "start_time": schedule["start_time"],
396
+ "end_time": schedule["end_time"],
397
+ "locked": False,
398
+ }
399
+ )
400
+
401
+ courses = store.list_courses()
402
+ for offset in range(7):
403
+ current_day = week_start + timedelta(days=offset)
404
+ current_week = get_academic_week(current_day, semester_start)
405
+ for course in courses:
406
+ if course_occurs_on(course, current_week, current_day):
407
+ scheduled_items.append(
408
+ {
409
+ "id": f"planner_{course['id']}_{current_day.isoformat()}",
410
+ "kind": "course",
411
+ "course_id": course["id"],
412
+ "title": course["title"],
413
+ "location": course.get("location", ""),
414
+ "date": current_day.isoformat(),
415
+ "start_time": course["start_time"],
416
+ "end_time": course["end_time"],
417
+ "locked": True,
418
+ "week_pattern": course["week_pattern"],
419
+ "color": course["color"],
420
+ "start_week": course["start_week"],
421
+ "end_week": course["end_week"],
422
+ }
423
+ )
424
+
425
+ scheduled_items.sort(
426
+ key=lambda item: (
427
+ item.get("date", ""),
428
+ item["start_time"],
429
+ item["end_time"],
430
+ item["kind"],
431
+ )
432
+ )
433
+
434
+ return {
435
+ "selected_date": selected_date.isoformat(),
436
+ "weekday": WEEKDAYS[selected_date.weekday()],
437
+ "week_start": week_start.isoformat(),
438
+ "week_end": week_end.isoformat(),
439
+ "week_days": week_days,
440
+ "week_range_label": f"{week_start.strftime('%m/%d')} - {week_end.strftime('%m/%d')}",
441
+ "academic_week": academic_week,
442
+ "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前",
443
+ "settings": settings,
444
+ "time_slots": CLASS_PERIODS,
445
+ "major_blocks": MAJOR_BLOCKS,
446
+ "tasks": task_list,
447
+ "scheduled_items": scheduled_items,
448
+ }
449
+
450
+
451
  @app.context_processor
452
  def inject_globals():
453
  return {
 
459
  def index():
460
  login_required = request.args.get("login") == "required"
461
  next_path = safe_next_path(request.args.get("next"))
462
+ planner_payload = build_week_planner_payload(beijing_now().date())
463
  return render_template(
464
  "index.html",
465
  categories=store.list_categories(),
 
488
  selected_date = parse_iso_date(request.args.get("date"))
489
  except ValueError as exc:
490
  return jsonify({"ok": False, "error": str(exc)}), 400
491
+ return jsonify({"ok": True, "planner": build_week_planner_payload(selected_date)})
492
 
493
 
494
  @app.post("/api/login")
static/app.js CHANGED
@@ -551,12 +551,8 @@
551
  logoutButton.addEventListener("click", handleLogout);
552
  }
553
 
554
- if (!state.authenticated) {
555
- const promptedKey = "drm-login-prompted";
556
- if (state.loginRequired || !window.sessionStorage.getItem(promptedKey)) {
557
- openModal(loginModal);
558
- window.sessionStorage.setItem(promptedKey, "1");
559
- }
560
  }
561
 
562
  renderClock();
 
551
  logoutButton.addEventListener("click", handleLogout);
552
  }
553
 
554
+ if (!state.authenticated && state.loginRequired) {
555
+ openModal(loginModal);
 
 
 
 
556
  }
557
 
558
  renderClock();
static/planner.js CHANGED
@@ -8,16 +8,16 @@
8
  dragTaskId: null,
9
  interaction: null,
10
  suppressClickUntil: 0,
 
11
  };
12
 
13
- const PIXELS_PER_MINUTE = 1.2;
14
- const AXIS_WIDTH = 118;
15
- const SLOT_WIDTH = 148;
16
- const CANVAS_GAP = 16;
 
17
  const SNAP_MINUTES = 5;
18
  const MIN_DURATION = 15;
19
- const MIN_BLOCK_HEIGHT = MIN_DURATION * PIXELS_PER_MINUTE;
20
- const AXIS_LABEL_MIN_GAP = 28;
21
  const CLICK_SUPPRESS_MS = 260;
22
 
23
  const pageTrack = document.getElementById("pageTrack");
@@ -33,10 +33,11 @@
33
  const plannerTaskCount = document.getElementById("plannerTaskCount");
34
  const plannerTaskPool = document.getElementById("plannerTaskPool");
35
  const plannerTimeline = document.getElementById("plannerTimeline");
 
36
  const loginModal = document.getElementById("loginModal");
37
  const toastStack = document.getElementById("toastStack");
38
 
39
- if (!pageTrack || !plannerDateInput || !plannerPrevDay || !plannerNextDay || !plannerTaskPool || !plannerTimeline) {
40
  return;
41
  }
42
 
@@ -139,9 +140,13 @@
139
  }).format(date).replace(",", "");
140
  }
141
 
142
- function formatPlannerDate(dateString) {
143
- const [year, month, day] = String(dateString).split("-").map(Number);
144
- return `${year} 年 ${String(month).padStart(2, "0")} 月 ${String(day).padStart(2, "0")} 日`;
 
 
 
 
145
  }
146
 
147
  function getPlannerConfig() {
@@ -161,8 +166,34 @@
161
  };
162
  }
163
 
164
- function getPlannerHeight() {
165
- return timelinePixels(getPlannerConfig().totalMinutes);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  }
167
 
168
  function getCanvasRect() {
@@ -177,10 +208,26 @@
177
  return dayStart;
178
  }
179
  const offsetY = clamp(clientY - rect.top, 0, rect.height);
180
- const minutes = dayStart + (offsetY / PIXELS_PER_MINUTE);
181
  return clamp(snapMinutes(minutes), dayStart, dayEnd);
182
  }
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  function getTaskById(taskId) {
185
  return (state.planner.tasks || []).find((task) => task.id === taskId) || null;
186
  }
@@ -193,15 +240,17 @@
193
  }
194
 
195
  function minutesToPixels(minutes) {
196
- return minutes * PIXELS_PER_MINUTE;
197
  }
198
 
199
  function timelinePixels(minutes) {
200
- return Math.round(minutesToPixels(minutes));
201
  }
202
 
203
  function getBlockHeight(startMinutes, endMinutes) {
204
- return Math.max(timelinePixels(endMinutes - startMinutes), Math.round(MIN_BLOCK_HEIGHT));
 
 
205
  }
206
 
207
  function setBlockBounds(block, startMinutes, endMinutes, dayStart) {
@@ -211,15 +260,31 @@
211
  return height;
212
  }
213
 
214
- function updateEventLayout(block, startMinutes, endMinutes, dayStart) {
215
- const height = setBlockBounds(block, startMinutes, endMinutes, dayStart);
216
- block.classList.toggle("is-compact", height < 64);
217
- block.classList.toggle("is-tight", height < 32);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return height;
219
  }
220
 
221
  function getPointerDeltaMinutes(pointerStartY, clientY) {
222
- return snapMinutes((clientY - pointerStartY) / PIXELS_PER_MINUTE);
223
  }
224
 
225
  function formatLessonLabel(index) {
@@ -230,6 +295,7 @@
230
  const axisMinutes = new Set([getPlannerConfig().dayStart, getPlannerConfig().dayEnd]);
231
  (state.planner.time_slots || []).forEach((slot) => {
232
  axisMinutes.add(toMinutes(slot.start));
 
233
  });
234
  return Array.from(axisMinutes).sort((left, right) => left - right);
235
  }
@@ -248,7 +314,19 @@
248
  suppressRecentClicks();
249
  }
250
 
251
- function setActivePage(index) {
 
 
 
 
 
 
 
 
 
 
 
 
252
  state.activePage = clamp(index, 0, 1);
253
  pageTrack.style.transform = `translateX(-${state.activePage * 100}%)`;
254
  pageSlides.forEach((slide, slideIndex) => {
@@ -257,11 +335,81 @@
257
  document.querySelectorAll(".story-tab").forEach((tab) => {
258
  tab.classList.toggle("is-active", Number(tab.dataset.goPage) === state.activePage);
259
  });
 
 
 
260
  if (state.activePage === 1) {
261
  loadPlanner(state.selectedDate, true);
262
  }
263
  }
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  function decorateItems(items) {
266
  const prepared = (items || [])
267
  .map((item) => ({
@@ -321,7 +469,7 @@
321
  if (!line) {
322
  return;
323
  }
324
- const { dayStart, dayEnd, canvasLeft } = getPlannerConfig();
325
  const parts = new Intl.DateTimeFormat("en-CA", {
326
  timeZone: "Asia/Shanghai",
327
  year: "numeric",
@@ -341,27 +489,31 @@
341
 
342
  const today = `${map.year}-${map.month}-${map.day}`;
343
  const currentMinutes = (Number(map.hour) * 60) + Number(map.minute);
 
344
 
345
- if (today !== state.selectedDate || currentMinutes < dayStart || currentMinutes > dayEnd) {
346
  line.style.display = "none";
347
  return;
348
  }
349
 
 
350
  line.style.display = "block";
351
- line.style.left = `${canvasLeft}px`;
352
- line.style.right = "14px";
353
  line.style.top = `${timelinePixels(currentMinutes - dayStart)}px`;
354
  }
355
 
356
  function renderTaskPool() {
357
- const tasks = (state.planner.tasks || []).filter((task) => !task.completed);
 
 
358
  plannerTaskCount.textContent = `${tasks.length} 项`;
359
 
360
  if (!tasks.length) {
361
  plannerTaskPool.innerHTML = `
362
  <div class="planner-empty">
363
  <p>目前没有可安排的任务</p>
364
- <span>先回到第一页添加待办,再把它拖到左侧时间。</span>
365
  </div>
366
  `;
367
  return;
@@ -376,9 +528,9 @@
376
  <div class="planner-task-tags">
377
  <span>截止 ${formatLocalDateTime(task.due_at)}</span>
378
  <span>进度 ${Math.round(task.progress_percent || 0)}%</span>
379
- <span>${task.schedule ? `${task.schedule.date} · ${task.schedule.start_time}-${task.schedule.end_time}` : "未排"}</span>
380
  </div>
381
- ${task.schedule && state.authenticated ? `<button class="planner-task-clear" type="button" data-clear-schedule="${task.id}">移出时间表</button>` : ""}
382
  </article>
383
  `).join("");
384
  }
@@ -626,6 +778,323 @@
626
  updateNowLine();
627
  }
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  function renderPlanner() {
630
  state.selectedDate = state.planner.selected_date || state.selectedDate;
631
  plannerDateInput.value = state.selectedDate;
@@ -645,7 +1114,7 @@
645
  });
646
  state.planner = payload.planner;
647
  state.selectedDate = payload.planner.selected_date;
648
- renderPlanner();
649
  } catch (error) {
650
  if (!silent) {
651
  showToast(error.message, "error");
@@ -721,7 +1190,9 @@
721
  const deltaMinutes = getPointerDeltaMinutes(state.interaction.pointerStartY, event.clientY);
722
 
723
  if (state.interaction.mode === "move") {
 
724
  const duration = state.interaction.initialEndMinutes - state.interaction.initialStartMinutes;
 
725
  const startMinutes = clamp(
726
  state.interaction.initialStartMinutes + deltaMinutes,
727
  dayStart,
@@ -736,6 +1207,7 @@
736
  dayStart,
737
  state.interaction.initialEndMinutes - MIN_DURATION
738
  );
 
739
  state.interaction.startMinutes = startMinutes;
740
  state.interaction.endMinutes = state.interaction.initialEndMinutes;
741
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
@@ -745,17 +1217,17 @@
745
  state.interaction.initialStartMinutes + MIN_DURATION,
746
  dayEnd
747
  );
 
748
  state.interaction.startMinutes = state.interaction.initialStartMinutes;
749
  state.interaction.endMinutes = endMinutes;
750
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
751
  }
752
 
753
- updateEventLayout(
754
- state.interaction.block,
755
- state.interaction.startMinutes,
756
- state.interaction.endMinutes,
757
- dayStart
758
- );
759
  updateEventTimeLabel(state.interaction.block, state.interaction.startMinutes, state.interaction.endMinutes);
760
  });
761
 
@@ -789,6 +1261,20 @@
789
  .catch((error) => showToast(error.message, "error"));
790
  }
791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  function finishInteraction(event) {
793
  if (!state.interaction) {
794
  return;
@@ -805,13 +1291,15 @@
805
  finishPlannerInteraction();
806
 
807
  if (
 
 
808
  current.startMinutes === current.initialStartMinutes
809
  && current.endMinutes === current.initialEndMinutes
810
  ) {
811
  return;
812
  }
813
 
814
- persistInteraction(current);
815
  }
816
 
817
  document.addEventListener("pointerup", finishInteraction);
@@ -825,11 +1313,11 @@
825
  });
826
 
827
  plannerPrevDay.addEventListener("click", () => {
828
- loadPlanner(shiftDate(state.selectedDate, -1));
829
  });
830
 
831
  plannerNextDay.addEventListener("click", () => {
832
- loadPlanner(shiftDate(state.selectedDate, 1));
833
  });
834
 
835
  window.setInterval(() => {
@@ -839,7 +1327,13 @@
839
  }, 45000);
840
 
841
  window.setInterval(updateNowLine, 60000);
842
- window.addEventListener("resize", updateNowLine);
 
 
 
 
 
 
843
 
844
  document.addEventListener("visibilitychange", () => {
845
  if (document.visibilityState === "visible" && state.activePage === 1) {
@@ -847,6 +1341,10 @@
847
  }
848
  });
849
 
850
- renderPlanner();
851
- setActivePage(0);
 
 
 
 
852
  })();
 
8
  dragTaskId: null,
9
  interaction: null,
10
  suppressClickUntil: 0,
11
+ pixelsPerMinute: 0.58,
12
  };
13
 
14
+ const DEFAULT_PIXELS_PER_MINUTE = 0.58;
15
+ const WEEK_HEADER_HEIGHT = 54;
16
+ const AXIS_WIDTH = 78;
17
+ const SLOT_WIDTH = 108;
18
+ const CANVAS_GAP = 12;
19
  const SNAP_MINUTES = 5;
20
  const MIN_DURATION = 15;
 
 
21
  const CLICK_SUPPRESS_MS = 260;
22
 
23
  const pageTrack = document.getElementById("pageTrack");
 
33
  const plannerTaskCount = document.getElementById("plannerTaskCount");
34
  const plannerTaskPool = document.getElementById("plannerTaskPool");
35
  const plannerTimeline = document.getElementById("plannerTimeline");
36
+ const timelineScroll = document.getElementById("timelineScroll");
37
  const loginModal = document.getElementById("loginModal");
38
  const toastStack = document.getElementById("toastStack");
39
 
40
+ if (!pageTrack || !plannerDateInput || !plannerPrevDay || !plannerNextDay || !plannerTaskPool || !plannerTimeline || !timelineScroll) {
41
  return;
42
  }
43
 
 
140
  }).format(date).replace(",", "");
141
  }
142
 
143
+ function formatWeekRange(weekStart, weekEnd) {
144
+ if (!weekStart || !weekEnd) {
145
+ return "";
146
+ }
147
+ const [startYear, startMonth, startDay] = String(weekStart).split("-").map(Number);
148
+ const [endYear, endMonth, endDay] = String(weekEnd).split("-").map(Number);
149
+ return `${startYear}.${String(startMonth).padStart(2, "0")}.${String(startDay).padStart(2, "0")} - ${endYear}.${String(endMonth).padStart(2, "0")}.${String(endDay).padStart(2, "0")}`;
150
  }
151
 
152
  function getPlannerConfig() {
 
166
  };
167
  }
168
 
169
+ function getWeekDays() {
170
+ return state.planner.week_days || [];
171
+ }
172
+
173
+ function getWeekDayIndex(dateIso) {
174
+ return getWeekDays().findIndex((day) => day.iso === dateIso);
175
+ }
176
+
177
+ function getWeekDayMeta(dateIso) {
178
+ return getWeekDays().find((day) => day.iso === dateIso) || null;
179
+ }
180
+
181
+ function computePlannerScale() {
182
+ const { totalMinutes } = getPlannerConfig();
183
+ const rect = timelineScroll.getBoundingClientRect();
184
+ const viewportAvailable = Math.max(Math.floor(window.innerHeight - rect.top - 18), 360);
185
+ const measuredHeight = Math.floor(timelineScroll.clientHeight || viewportAvailable);
186
+ const frameHeight = Math.max(Math.min(measuredHeight, viewportAvailable), 360);
187
+ const bodyHeight = Math.max(frameHeight - WEEK_HEADER_HEIGHT - 4, 300);
188
+ state.pixelsPerMinute = bodyHeight / totalMinutes;
189
+ return {
190
+ frameHeight,
191
+ bodyHeight,
192
+ };
193
+ }
194
+
195
+ function getPixelsPerMinute() {
196
+ return state.pixelsPerMinute || DEFAULT_PIXELS_PER_MINUTE;
197
  }
198
 
199
  function getCanvasRect() {
 
208
  return dayStart;
209
  }
210
  const offsetY = clamp(clientY - rect.top, 0, rect.height);
211
+ const minutes = dayStart + (offsetY / getPixelsPerMinute());
212
  return clamp(snapMinutes(minutes), dayStart, dayEnd);
213
  }
214
 
215
+ function clientPointToSchedule(clientX, clientY) {
216
+ const rect = getCanvasRect();
217
+ const days = getWeekDays();
218
+ if (!rect || !days.length) {
219
+ return null;
220
+ }
221
+ const relativeX = clamp(clientX - rect.left, 0, Math.max(rect.width - 1, 0));
222
+ const columnWidth = rect.width / days.length;
223
+ const dayIndex = clamp(Math.floor(relativeX / columnWidth), 0, days.length - 1);
224
+ return {
225
+ date: days[dayIndex].iso,
226
+ minutes: clientYToMinutes(clientY),
227
+ dayIndex,
228
+ };
229
+ }
230
+
231
  function getTaskById(taskId) {
232
  return (state.planner.tasks || []).find((task) => task.id === taskId) || null;
233
  }
 
240
  }
241
 
242
  function minutesToPixels(minutes) {
243
+ return Math.round(minutes * getPixelsPerMinute());
244
  }
245
 
246
  function timelinePixels(minutes) {
247
+ return minutesToPixels(minutes);
248
  }
249
 
250
  function getBlockHeight(startMinutes, endMinutes) {
251
+ const scaledHeight = timelinePixels(endMinutes - startMinutes);
252
+ const minimumHeight = Math.max(28, Math.round(getPixelsPerMinute() * MIN_DURATION));
253
+ return Math.max(scaledHeight, minimumHeight);
254
  }
255
 
256
  function setBlockBounds(block, startMinutes, endMinutes, dayStart) {
 
260
  return height;
261
  }
262
 
263
+ function setBlockHorizontalBounds(block, dateIso, overlapIndex = 0, overlapCount = 1) {
264
+ const days = getWeekDays();
265
+ const dayIndex = getWeekDayIndex(dateIso);
266
+ if (dayIndex < 0 || !days.length) {
267
+ return;
268
+ }
269
+ const dayWidthPercent = 100 / days.length;
270
+ const segmentWidth = dayWidthPercent / overlapCount;
271
+ const leftPercent = (dayIndex * dayWidthPercent) + (overlapIndex * segmentWidth);
272
+ block.style.left = `calc(${leftPercent}% + 4px)`;
273
+ block.style.width = `calc(${segmentWidth}% - 8px)`;
274
+ block.dataset.eventDate = dateIso;
275
+ }
276
+
277
+ function updateEventLayout(block, item, dayStart, overlapIndex = 0, overlapCount = 1) {
278
+ const height = setBlockBounds(block, item.startMinutes, item.endMinutes, dayStart);
279
+ setBlockHorizontalBounds(block, item.date, overlapIndex, overlapCount);
280
+ block.classList.toggle("is-compact", height < 66);
281
+ block.classList.toggle("is-tight", height < 40);
282
+ block.classList.toggle("is-micro", height < 24);
283
  return height;
284
  }
285
 
286
  function getPointerDeltaMinutes(pointerStartY, clientY) {
287
+ return snapMinutes((clientY - pointerStartY) / getPixelsPerMinute());
288
  }
289
 
290
  function formatLessonLabel(index) {
 
295
  const axisMinutes = new Set([getPlannerConfig().dayStart, getPlannerConfig().dayEnd]);
296
  (state.planner.time_slots || []).forEach((slot) => {
297
  axisMinutes.add(toMinutes(slot.start));
298
+ axisMinutes.add(toMinutes(slot.end));
299
  });
300
  return Array.from(axisMinutes).sort((left, right) => left - right);
301
  }
 
314
  suppressRecentClicks();
315
  }
316
 
317
+ function getPageIndexFromHash() {
318
+ const hash = String(window.location.hash || "").toLowerCase();
319
+ return hash === "#planner" || hash === "#page2" || hash === "#week" ? 1 : 0;
320
+ }
321
+
322
+ function syncPageHash(index) {
323
+ const nextHash = index === 1 ? "#planner" : "#home";
324
+ if (window.location.hash !== nextHash) {
325
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${nextHash}`);
326
+ }
327
+ }
328
+
329
+ function setActivePage(index, options = {}) {
330
  state.activePage = clamp(index, 0, 1);
331
  pageTrack.style.transform = `translateX(-${state.activePage * 100}%)`;
332
  pageSlides.forEach((slide, slideIndex) => {
 
335
  document.querySelectorAll(".story-tab").forEach((tab) => {
336
  tab.classList.toggle("is-active", Number(tab.dataset.goPage) === state.activePage);
337
  });
338
+ if (!options.skipHash) {
339
+ syncPageHash(state.activePage);
340
+ }
341
  if (state.activePage === 1) {
342
  loadPlanner(state.selectedDate, true);
343
  }
344
  }
345
 
346
+ function formatTimelineSlotLabel(index) {
347
+ return `第${String(index + 1).padStart(2, "0")}节`;
348
+ }
349
+
350
+ function decorateScheduleItems(items) {
351
+ const grouped = new Map();
352
+
353
+ (items || []).forEach((item) => {
354
+ const prepared = {
355
+ ...item,
356
+ date: item.date || state.selectedDate,
357
+ startMinutes: toMinutes(item.start_time),
358
+ endMinutes: toMinutes(item.end_time),
359
+ };
360
+ if (!grouped.has(prepared.date)) {
361
+ grouped.set(prepared.date, []);
362
+ }
363
+ grouped.get(prepared.date).push(prepared);
364
+ });
365
+
366
+ const result = [];
367
+ getWeekDays().forEach((day) => {
368
+ const prepared = (grouped.get(day.iso) || [])
369
+ .sort((left, right) => (
370
+ left.startMinutes - right.startMinutes
371
+ || left.endMinutes - right.endMinutes
372
+ || left.kind.localeCompare(right.kind)
373
+ ));
374
+
375
+ let active = [];
376
+ let currentGroup = [];
377
+ let currentGroupWidth = 0;
378
+
379
+ function finalizeGroup() {
380
+ currentGroup.forEach((item) => {
381
+ item.columnCount = currentGroupWidth || 1;
382
+ });
383
+ currentGroup = [];
384
+ currentGroupWidth = 0;
385
+ }
386
+
387
+ prepared.forEach((item) => {
388
+ active = active.filter((activeItem) => activeItem.endMinutes > item.startMinutes);
389
+ if (!active.length && currentGroup.length) {
390
+ finalizeGroup();
391
+ }
392
+ const usedColumns = new Set(active.map((activeItem) => activeItem.column));
393
+ let column = 0;
394
+ while (usedColumns.has(column)) {
395
+ column += 1;
396
+ }
397
+ item.column = column;
398
+ active.push(item);
399
+ currentGroup.push(item);
400
+ currentGroupWidth = Math.max(currentGroupWidth, active.length);
401
+ });
402
+
403
+ if (currentGroup.length) {
404
+ finalizeGroup();
405
+ }
406
+
407
+ result.push(...prepared);
408
+ });
409
+
410
+ return result;
411
+ }
412
+
413
  function decorateItems(items) {
414
  const prepared = (items || [])
415
  .map((item) => ({
 
469
  if (!line) {
470
  return;
471
  }
472
+ const { dayStart, dayEnd } = getPlannerConfig();
473
  const parts = new Intl.DateTimeFormat("en-CA", {
474
  timeZone: "Asia/Shanghai",
475
  year: "numeric",
 
489
 
490
  const today = `${map.year}-${map.month}-${map.day}`;
491
  const currentMinutes = (Number(map.hour) * 60) + Number(map.minute);
492
+ const dayIndex = getWeekDayIndex(today);
493
 
494
+ if (dayIndex < 0 || currentMinutes < dayStart || currentMinutes > dayEnd) {
495
  line.style.display = "none";
496
  return;
497
  }
498
 
499
+ const dayWidthPercent = 100 / getWeekDays().length;
500
  line.style.display = "block";
501
+ line.style.left = `calc(${dayIndex * dayWidthPercent}% + 4px)`;
502
+ line.style.width = `calc(${dayWidthPercent}% - 8px)`;
503
  line.style.top = `${timelinePixels(currentMinutes - dayStart)}px`;
504
  }
505
 
506
  function renderTaskPool() {
507
+ const tasks = (state.planner.tasks || [])
508
+ .filter((task) => !task.completed)
509
+ .sort((left, right) => Number(!!left.schedule) - Number(!!right.schedule));
510
  plannerTaskCount.textContent = `${tasks.length} 项`;
511
 
512
  if (!tasks.length) {
513
  plannerTaskPool.innerHTML = `
514
  <div class="planner-empty">
515
  <p>目前没有可安排的任务</p>
516
+ <span>先回到第一页添加待办,再把它拖到本周课。</span>
517
  </div>
518
  `;
519
  return;
 
528
  <div class="planner-task-tags">
529
  <span>截止 ${formatLocalDateTime(task.due_at)}</span>
530
  <span>进度 ${Math.round(task.progress_percent || 0)}%</span>
531
+ <span>${task.schedule ? `${task.schedule.date} · ${task.schedule.start_time}-${task.schedule.end_time}` : "未排入周表"}</span>
532
  </div>
533
+ ${task.schedule && state.authenticated ? `<button class="planner-task-clear" type="button" data-clear-schedule="${task.id}">移出排程</button>` : ""}
534
  </article>
535
  `).join("");
536
  }
 
778
  updateNowLine();
779
  }
780
 
781
+ function renderWeekTimeline() {
782
+ const { dayStart, dayEnd, canvasLeft } = getPlannerConfig();
783
+ const { frameHeight, bodyHeight } = computePlannerScale();
784
+ const days = getWeekDays();
785
+
786
+ plannerTimeline.innerHTML = "";
787
+ plannerTimeline.style.height = `${frameHeight}px`;
788
+ plannerTimeline.style.setProperty("--timeline-axis-width", `${AXIS_WIDTH}px`);
789
+ plannerTimeline.style.setProperty("--timeline-slot-width", `${SLOT_WIDTH}px`);
790
+ plannerTimeline.style.setProperty("--timeline-canvas-left", `${canvasLeft}px`);
791
+ plannerTimeline.style.setProperty("--timeline-week-header-height", `${WEEK_HEADER_HEIGHT}px`);
792
+
793
+ if (!days.length) {
794
+ return;
795
+ }
796
+
797
+ const headerLayer = document.createElement("div");
798
+ headerLayer.className = "timeline-week-header";
799
+ headerLayer.style.left = `${canvasLeft}px`;
800
+ headerLayer.style.right = "12px";
801
+ headerLayer.style.height = `${WEEK_HEADER_HEIGHT - 8}px`;
802
+
803
+ days.forEach((day) => {
804
+ const head = document.createElement("button");
805
+ head.type = "button";
806
+ head.className = `timeline-day-head ${day.is_today ? "is-today" : ""} ${day.iso === state.selectedDate ? "is-selected" : ""}`;
807
+ head.innerHTML = `
808
+ <strong>${escapeHtml(day.short_label)}</strong>
809
+ <span>${escapeHtml(day.month_day)}</span>
810
+ `;
811
+ head.addEventListener("click", () => {
812
+ if (day.iso !== state.selectedDate) {
813
+ loadPlanner(day.iso, true);
814
+ }
815
+ });
816
+ headerLayer.appendChild(head);
817
+ });
818
+
819
+ const axisLayer = document.createElement("div");
820
+ axisLayer.className = "timeline-axis-layer";
821
+ axisLayer.style.top = `${WEEK_HEADER_HEIGHT}px`;
822
+ axisLayer.style.height = `${bodyHeight}px`;
823
+
824
+ const slotLayer = document.createElement("div");
825
+ slotLayer.className = "timeline-slot-layer";
826
+ slotLayer.style.top = `${WEEK_HEADER_HEIGHT}px`;
827
+ slotLayer.style.height = `${bodyHeight}px`;
828
+
829
+ const canvasLayer = document.createElement("div");
830
+ canvasLayer.className = "timeline-canvas-layer";
831
+ canvasLayer.style.top = `${WEEK_HEADER_HEIGHT}px`;
832
+ canvasLayer.style.height = `${bodyHeight}px`;
833
+
834
+ const axisRail = document.createElement("div");
835
+ axisRail.className = "timeline-axis-rail";
836
+ axisLayer.appendChild(axisRail);
837
+
838
+ const lineMarkers = new Set([dayStart, dayEnd]);
839
+ const timeSlots = state.planner.time_slots || [];
840
+ const dayWidthPercent = 100 / days.length;
841
+
842
+ days.forEach((day, dayIndex) => {
843
+ const dayColumn = document.createElement("div");
844
+ dayColumn.className = `timeline-day-column ${day.is_today ? "is-today" : ""} ${day.iso === state.selectedDate ? "is-selected" : ""}`;
845
+ dayColumn.style.left = `${dayIndex * dayWidthPercent}%`;
846
+ dayColumn.style.width = `${dayWidthPercent}%`;
847
+ canvasLayer.appendChild(dayColumn);
848
+
849
+ if (dayIndex > 0) {
850
+ const divider = document.createElement("div");
851
+ divider.className = "timeline-day-divider";
852
+ divider.style.left = `${dayIndex * dayWidthPercent}%`;
853
+ canvasLayer.appendChild(divider);
854
+ }
855
+ });
856
+
857
+ timeSlots.forEach((slot, slotIndex) => {
858
+ const slotStart = toMinutes(slot.start);
859
+ const slotEnd = toMinutes(slot.end);
860
+ lineMarkers.add(slotStart);
861
+ lineMarkers.add(slotEnd);
862
+
863
+ const band = document.createElement("div");
864
+ band.className = "timeline-slot-band";
865
+ band.style.top = `${timelinePixels(slotStart - dayStart)}px`;
866
+ band.style.height = `${timelinePixels(slotEnd - slotStart)}px`;
867
+ band.innerHTML = `
868
+ <strong>${formatTimelineSlotLabel(slotIndex)}</strong>
869
+ <span>${escapeHtml(slot.start)} - ${escapeHtml(slot.end)}</span>
870
+ `;
871
+ slotLayer.appendChild(band);
872
+ });
873
+
874
+ Array.from(lineMarkers)
875
+ .sort((left, right) => left - right)
876
+ .forEach((minute) => {
877
+ const line = document.createElement("div");
878
+ line.className = "timeline-line is-slot";
879
+ line.style.top = `${timelinePixels(minute - dayStart)}px`;
880
+ canvasLayer.appendChild(line);
881
+ });
882
+
883
+ getTimelineAxisMinutes().forEach((minute, axisIndex, axisMinutes) => {
884
+ const tick = document.createElement("div");
885
+ tick.className = "timeline-axis-tick";
886
+ if (axisIndex === 0) {
887
+ tick.classList.add("is-leading");
888
+ } else if (axisIndex === axisMinutes.length - 1) {
889
+ tick.classList.add("is-terminal");
890
+ }
891
+ tick.style.top = `${timelinePixels(minute - dayStart)}px`;
892
+ tick.textContent = minutesToTime(minute);
893
+ axisLayer.appendChild(tick);
894
+ });
895
+
896
+ const majorMap = new Map();
897
+ (state.planner.major_blocks || []).forEach((block) => {
898
+ if (!majorMap.has(block.label)) {
899
+ majorMap.set(block.label, block);
900
+ }
901
+ });
902
+
903
+ Array.from(majorMap.values()).forEach((block, blockIndex) => {
904
+ const startMinutes = toMinutes(block.start);
905
+ const endMinutes = toMinutes(block.end);
906
+ const overlay = document.createElement("div");
907
+ overlay.className = "timeline-major-block";
908
+ overlay.style.top = `${timelinePixels(startMinutes - dayStart)}px`;
909
+ overlay.style.height = `${timelinePixels(endMinutes - startMinutes)}px`;
910
+ overlay.innerHTML = `<span>${escapeHtml(block.label || `第${blockIndex + 1}大节`)}</span>`;
911
+ canvasLayer.appendChild(overlay);
912
+ });
913
+
914
+ decorateScheduleItems(state.planner.scheduled_items).forEach((item) => {
915
+ const block = document.createElement("article");
916
+ block.className = `planner-event ${item.kind === "course" ? "course-event" : "task-event"} ${item.completed ? "is-complete" : ""}`;
917
+ updateEventLayout(block, item, dayStart, item.column || 0, item.columnCount || 1);
918
+
919
+ if (item.kind === "course") {
920
+ if (item.color) {
921
+ block.style.setProperty("--event-accent", item.color);
922
+ }
923
+ block.innerHTML = `
924
+ <div class="planner-event-top">
925
+ <strong>${escapeHtml(item.title)}</strong>
926
+ <span class="planner-lock-badge">固定课程</span>
927
+ </div>
928
+ <div class="planner-event-meta">
929
+ <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
930
+ <span>${escapeHtml(item.location || "")}</span>
931
+ </div>
932
+ `;
933
+ } else {
934
+ block.dataset.taskId = item.task_id;
935
+ block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0));
936
+ block.innerHTML = `
937
+ <div class="planner-event-top">
938
+ <strong>${escapeHtml(item.title)}</strong>
939
+ <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
940
+ </div>
941
+ <div class="planner-event-meta">
942
+ <span>${escapeHtml(item.category_name)}</span>
943
+ <span>进度 ${Math.round(item.progress_percent || 0)}%</span>
944
+ </div>
945
+ ${state.authenticated ? `<button class="planner-event-clear" type="button" data-clear-schedule="${item.task_id}">移出</button>` : ""}
946
+ ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-top" data-resize-task-start="${item.task_id}"></div>` : ""}
947
+ ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-bottom" data-resize-task-end="${item.task_id}"></div>` : ""}
948
+ `;
949
+
950
+ if (state.authenticated) {
951
+ block.addEventListener("pointerdown", (event) => {
952
+ if (event.button !== 0 || event.target.closest("[data-clear-schedule]")) {
953
+ return;
954
+ }
955
+
956
+ event.preventDefault();
957
+ event.stopPropagation();
958
+
959
+ const point = clientPointToSchedule(event.clientX, event.clientY);
960
+ const mode = event.target.closest("[data-resize-task-start]")
961
+ ? "resize-start"
962
+ : event.target.closest("[data-resize-task-end]")
963
+ ? "resize-end"
964
+ : "move";
965
+
966
+ beginPlannerInteraction();
967
+ state.interaction = {
968
+ mode,
969
+ block,
970
+ taskId: item.task_id,
971
+ pointerId: event.pointerId,
972
+ pointerStartX: event.clientX,
973
+ pointerStartY: event.clientY,
974
+ initialDate: item.date,
975
+ currentDate: item.date,
976
+ initialStartMinutes: item.startMinutes,
977
+ initialEndMinutes: item.endMinutes,
978
+ startMinutes: item.startMinutes,
979
+ endMinutes: item.endMinutes,
980
+ duration: item.endMinutes - item.startMinutes,
981
+ pointerOffsetMinutes: point ? point.minutes - item.startMinutes : 0,
982
+ };
983
+
984
+ if (typeof block.setPointerCapture === "function") {
985
+ try {
986
+ block.setPointerCapture(event.pointerId);
987
+ } catch (error) {
988
+ // Ignore browsers that reject capture for synthetic pointer sequences.
989
+ }
990
+ }
991
+
992
+ block.classList.add("is-dragging");
993
+ });
994
+ }
995
+ }
996
+
997
+ canvasLayer.appendChild(block);
998
+ });
999
+
1000
+ const preview = document.createElement("div");
1001
+ preview.className = "timeline-drop-preview";
1002
+ preview.id = "timelineDropPreview";
1003
+ preview.style.display = "none";
1004
+ canvasLayer.appendChild(preview);
1005
+
1006
+ const nowLine = document.createElement("div");
1007
+ nowLine.className = "timeline-now-line";
1008
+ nowLine.id = "timelineNowLine";
1009
+ canvasLayer.appendChild(nowLine);
1010
+
1011
+ canvasLayer.addEventListener("dragover", (event) => {
1012
+ if (!state.dragTaskId) {
1013
+ return;
1014
+ }
1015
+ event.preventDefault();
1016
+ const task = getTaskById(state.dragTaskId);
1017
+ const point = clientPointToSchedule(event.clientX, event.clientY);
1018
+ if (!task || !point) {
1019
+ preview.style.display = "none";
1020
+ return;
1021
+ }
1022
+ const duration = getTaskDuration(task);
1023
+ const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
1024
+ const dayMeta = getWeekDayMeta(point.date);
1025
+ preview.style.display = "grid";
1026
+ setBlockBounds(preview, startMinutes, startMinutes + duration, dayStart);
1027
+ setBlockHorizontalBounds(preview, point.date, 0, 1);
1028
+ preview.innerHTML = `
1029
+ <strong>${escapeHtml(dayMeta ? dayMeta.short_label : "")}</strong>
1030
+ <span>${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}</span>
1031
+ `;
1032
+ });
1033
+
1034
+ canvasLayer.addEventListener("dragleave", (event) => {
1035
+ if (!canvasLayer.contains(event.relatedTarget)) {
1036
+ preview.style.display = "none";
1037
+ }
1038
+ });
1039
+
1040
+ canvasLayer.addEventListener("drop", async (event) => {
1041
+ if (!state.dragTaskId) {
1042
+ return;
1043
+ }
1044
+ event.preventDefault();
1045
+ preview.style.display = "none";
1046
+ if (!requireAuth()) {
1047
+ state.dragTaskId = null;
1048
+ return;
1049
+ }
1050
+
1051
+ const task = getTaskById(state.dragTaskId);
1052
+ const point = clientPointToSchedule(event.clientX, event.clientY);
1053
+ if (!task || !point) {
1054
+ state.dragTaskId = null;
1055
+ return;
1056
+ }
1057
+
1058
+ try {
1059
+ const duration = getTaskDuration(task);
1060
+ const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration);
1061
+ await requestJSON(`/api/tasks/${task.id}/schedule`, {
1062
+ method: "PATCH",
1063
+ body: JSON.stringify({
1064
+ date: point.date,
1065
+ start_time: minutesToTime(startMinutes),
1066
+ end_time: minutesToTime(startMinutes + duration),
1067
+ }),
1068
+ });
1069
+ await loadPlanner(point.date, true);
1070
+ showToast("任务已拖入本周课表");
1071
+ } catch (error) {
1072
+ showToast(error.message, "error");
1073
+ } finally {
1074
+ state.dragTaskId = null;
1075
+ }
1076
+ });
1077
+
1078
+ plannerTimeline.appendChild(headerLayer);
1079
+ plannerTimeline.appendChild(axisLayer);
1080
+ plannerTimeline.appendChild(slotLayer);
1081
+ plannerTimeline.appendChild(canvasLayer);
1082
+
1083
+ updateNowLine();
1084
+ }
1085
+
1086
+ function renderWeekPlanner() {
1087
+ state.selectedDate = state.planner.selected_date || state.selectedDate;
1088
+ plannerDateInput.value = state.selectedDate;
1089
+ plannerDateLabel.textContent = formatWeekRange(state.planner.week_start, state.planner.week_end);
1090
+ plannerWeekday.textContent = state.planner.week_range_label || "";
1091
+ plannerAcademicWeek.textContent = state.planner.academic_label || "";
1092
+ plannerWindow.textContent = `${state.planner.settings.day_start} - ${state.planner.settings.day_end}`;
1093
+ plannerHeadlineNote.textContent = "课程会按学期周次自动固定显示,任务可拖入本周任意一天,并通过上下边缘拉伸;每项任务最短 15 分钟。";
1094
+ renderTaskPool();
1095
+ renderWeekTimeline();
1096
+ }
1097
+
1098
  function renderPlanner() {
1099
  state.selectedDate = state.planner.selected_date || state.selectedDate;
1100
  plannerDateInput.value = state.selectedDate;
 
1114
  });
1115
  state.planner = payload.planner;
1116
  state.selectedDate = payload.planner.selected_date;
1117
+ renderWeekPlanner();
1118
  } catch (error) {
1119
  if (!silent) {
1120
  showToast(error.message, "error");
 
1190
  const deltaMinutes = getPointerDeltaMinutes(state.interaction.pointerStartY, event.clientY);
1191
 
1192
  if (state.interaction.mode === "move") {
1193
+ const point = clientPointToSchedule(event.clientX, event.clientY);
1194
  const duration = state.interaction.initialEndMinutes - state.interaction.initialStartMinutes;
1195
+ state.interaction.currentDate = point ? point.date : state.interaction.initialDate;
1196
  const startMinutes = clamp(
1197
  state.interaction.initialStartMinutes + deltaMinutes,
1198
  dayStart,
 
1207
  dayStart,
1208
  state.interaction.initialEndMinutes - MIN_DURATION
1209
  );
1210
+ state.interaction.currentDate = state.interaction.initialDate;
1211
  state.interaction.startMinutes = startMinutes;
1212
  state.interaction.endMinutes = state.interaction.initialEndMinutes;
1213
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
 
1217
  state.interaction.initialStartMinutes + MIN_DURATION,
1218
  dayEnd
1219
  );
1220
+ state.interaction.currentDate = state.interaction.initialDate;
1221
  state.interaction.startMinutes = state.interaction.initialStartMinutes;
1222
  state.interaction.endMinutes = endMinutes;
1223
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
1224
  }
1225
 
1226
+ updateEventLayout(state.interaction.block, {
1227
+ date: state.interaction.currentDate,
1228
+ startMinutes: state.interaction.startMinutes,
1229
+ endMinutes: state.interaction.endMinutes,
1230
+ }, dayStart);
 
1231
  updateEventTimeLabel(state.interaction.block, state.interaction.startMinutes, state.interaction.endMinutes);
1232
  });
1233
 
 
1261
  .catch((error) => showToast(error.message, "error"));
1262
  }
1263
 
1264
+ function persistWeekInteraction(current) {
1265
+ requestJSON(`/api/tasks/${current.taskId}/schedule`, {
1266
+ method: "PATCH",
1267
+ body: JSON.stringify({
1268
+ date: current.currentDate || current.initialDate || state.selectedDate,
1269
+ start_time: minutesToTime(current.startMinutes),
1270
+ end_time: minutesToTime(current.endMinutes),
1271
+ }),
1272
+ })
1273
+ .then(() => loadPlanner(current.currentDate || state.selectedDate, true))
1274
+ .then(() => showToast("规划时间已更新"))
1275
+ .catch((error) => showToast(error.message, "error"));
1276
+ }
1277
+
1278
  function finishInteraction(event) {
1279
  if (!state.interaction) {
1280
  return;
 
1291
  finishPlannerInteraction();
1292
 
1293
  if (
1294
+ (current.currentDate || current.initialDate) === current.initialDate
1295
+ &&
1296
  current.startMinutes === current.initialStartMinutes
1297
  && current.endMinutes === current.initialEndMinutes
1298
  ) {
1299
  return;
1300
  }
1301
 
1302
+ persistWeekInteraction(current);
1303
  }
1304
 
1305
  document.addEventListener("pointerup", finishInteraction);
 
1313
  });
1314
 
1315
  plannerPrevDay.addEventListener("click", () => {
1316
+ loadPlanner(shiftDate(state.selectedDate, -7));
1317
  });
1318
 
1319
  plannerNextDay.addEventListener("click", () => {
1320
+ loadPlanner(shiftDate(state.selectedDate, 7));
1321
  });
1322
 
1323
  window.setInterval(() => {
 
1327
  }, 45000);
1328
 
1329
  window.setInterval(updateNowLine, 60000);
1330
+ window.addEventListener("resize", () => {
1331
+ if (state.activePage === 1) {
1332
+ renderWeekPlanner();
1333
+ } else {
1334
+ updateNowLine();
1335
+ }
1336
+ });
1337
 
1338
  document.addEventListener("visibilitychange", () => {
1339
  if (document.visibilityState === "visible" && state.activePage === 1) {
 
1341
  }
1342
  });
1343
 
1344
+ window.addEventListener("hashchange", () => {
1345
+ setActivePage(getPageIndexFromHash(), { skipHash: true });
1346
+ });
1347
+
1348
+ renderWeekPlanner();
1349
+ setActivePage(getPageIndexFromHash(), { skipHash: true });
1350
  })();
static/v020.css CHANGED
@@ -1,19 +1,32 @@
 
 
 
 
 
 
 
 
 
 
1
  .story-layout {
2
- padding-top: 20px;
 
3
  }
4
 
5
  .story-shell {
6
  display: grid;
7
- gap: 18px;
8
  min-width: 0;
 
 
9
  }
10
 
11
  .story-toolbar {
12
- border-radius: 28px;
13
- padding: 16px 20px;
14
  display: grid;
15
  grid-template-columns: auto 1fr auto;
16
- gap: 18px;
17
  align-items: center;
18
  }
19
 
@@ -44,12 +57,15 @@
44
  position: relative;
45
  width: 100%;
46
  overflow: hidden;
 
 
47
  }
48
 
49
  .page-track {
50
  display: flex;
51
  width: 100%;
52
- align-items: flex-start;
 
53
  transition: transform 720ms cubic-bezier(0.22, 1, 0.36, 1);
54
  will-change: transform;
55
  }
@@ -61,6 +77,8 @@
61
  min-width: 100%;
62
  overflow: hidden;
63
  pointer-events: none;
 
 
64
  }
65
 
66
  .page-slide.is-active {
@@ -73,32 +91,145 @@ body.planner-interacting * {
73
  -webkit-user-select: none !important;
74
  }
75
 
 
 
 
 
 
 
 
 
76
  .page-home {
77
  display: grid;
78
- gap: 18px;
79
- grid-template-rows: minmax(250px, 28vh) 1fr;
80
- padding-right: 18px;
 
81
  }
82
 
83
  .page-planner {
84
- padding-left: 18px;
 
85
  }
86
 
87
  .inline-link {
88
  border: 0;
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  .hero-copy {
92
- margin: 8px 0 0;
93
  color: var(--muted);
94
  }
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  .planner-shell {
97
- border-radius: 30px;
98
- padding: 24px;
99
- min-height: calc(100vh - 170px);
 
100
  display: grid;
101
- gap: 16px;
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
 
104
  .planner-head,
@@ -116,17 +247,17 @@ body.planner-interacting * {
116
  .planner-head,
117
  .planner-sidebar-head {
118
  justify-content: space-between;
119
- gap: 16px;
120
  }
121
 
122
  .planner-controls {
123
- gap: 10px;
124
  }
125
 
126
  .planner-date-picker {
127
  display: grid;
128
- gap: 8px;
129
- min-width: 180px;
130
  }
131
 
132
  .planner-date-picker span {
@@ -136,9 +267,9 @@ body.planner-interacting * {
136
 
137
  .planner-date-picker input,
138
  .modal-form select {
139
- min-height: 46px;
140
- padding: 0 14px;
141
- border-radius: 16px;
142
  border: 1px solid rgba(255, 255, 255, 0.1);
143
  background: rgba(255, 255, 255, 0.04);
144
  color: var(--text);
@@ -147,7 +278,7 @@ body.planner-interacting * {
147
 
148
  .planner-meta {
149
  flex-wrap: wrap;
150
- gap: 10px;
151
  }
152
 
153
  .planner-meta span,
@@ -155,17 +286,19 @@ body.planner-interacting * {
155
  .planner-task-category {
156
  display: inline-flex;
157
  align-items: center;
158
- padding: 8px 12px;
159
  border-radius: 999px;
160
  background: rgba(255, 255, 255, 0.06);
161
  color: var(--muted-strong);
 
162
  }
163
 
164
  .planner-layout {
165
  display: grid;
166
- grid-template-columns: minmax(0, 7fr) minmax(290px, 3fr);
167
- gap: 18px;
168
  min-height: 0;
 
169
  }
170
 
171
  .timeline-surface,
@@ -176,19 +309,24 @@ body.planner-interacting * {
176
  }
177
 
178
  .timeline-surface {
179
- padding: 12px;
 
 
180
  }
181
 
182
  .timeline-scroll {
183
- overflow: auto;
184
- max-height: calc(100vh - 280px);
185
- border-radius: 22px;
 
 
186
  background: rgba(3, 11, 20, 0.46);
187
  }
188
 
189
  .timeline-grid {
190
  position: relative;
191
- min-height: 1000px;
 
192
  }
193
 
194
  .timeline-axis-layer,
@@ -201,6 +339,14 @@ body.planner-interacting * {
201
  bottom: 0;
202
  }
203
 
 
 
 
 
 
 
 
 
204
  .timeline-axis-layer {
205
  left: 0;
206
  width: var(--timeline-axis-width);
@@ -218,6 +364,74 @@ body.planner-interacting * {
218
  right: 12px;
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  .timeline-axis-rail {
222
  position: absolute;
223
  top: 0;
@@ -234,11 +448,11 @@ body.planner-interacting * {
234
  display: flex;
235
  justify-content: flex-end;
236
  align-items: center;
237
- gap: 12px;
238
  transform: translateY(-50%);
239
- padding-right: 26px;
240
  color: rgba(238, 244, 251, 0.76);
241
- font-size: 0.8rem;
242
  font-variant-numeric: tabular-nums;
243
  letter-spacing: 0.02em;
244
  line-height: 1;
@@ -247,8 +461,8 @@ body.planner-interacting * {
247
 
248
  .timeline-axis-tick::after {
249
  content: "";
250
- width: 8px;
251
- height: 8px;
252
  border-radius: 999px;
253
  background: rgba(123, 231, 234, 0.78);
254
  box-shadow: 0 0 0 4px rgba(123, 231, 234, 0.12);
@@ -265,29 +479,29 @@ body.planner-interacting * {
265
 
266
  .timeline-slot-band {
267
  position: absolute;
268
- left: 8px;
269
- right: 10px;
270
  box-sizing: border-box;
271
  overflow: hidden;
272
- padding: 8px 10px;
273
- border-radius: 16px;
274
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025));
275
  border: 1px solid rgba(255, 255, 255, 0.08);
276
  display: flex;
277
  flex-direction: column;
278
  justify-content: center;
279
- gap: 4px;
280
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
281
  }
282
 
283
  .timeline-slot-band strong {
284
- font-size: 0.86rem;
285
  line-height: 1.1;
286
  }
287
 
288
  .timeline-slot-band span {
289
  color: var(--muted);
290
- font-size: 0.74rem;
291
  line-height: 1.1;
292
  font-variant-numeric: tabular-nums;
293
  }
@@ -308,17 +522,17 @@ body.planner-interacting * {
308
  left: 0;
309
  right: 0;
310
  box-sizing: border-box;
311
- border-radius: 22px;
312
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.015) 100%);
313
  pointer-events: none;
314
  }
315
 
316
  .timeline-major-block span {
317
  position: absolute;
318
- top: 14px;
319
- right: 18px;
320
  font-family: "Sora", "Noto Sans SC", sans-serif;
321
- font-size: clamp(1.4rem, 1.9vw, 2.1rem);
322
  color: rgba(238, 244, 251, 0.16);
323
  }
324
 
@@ -327,8 +541,8 @@ body.planner-interacting * {
327
  position: absolute;
328
  box-sizing: border-box;
329
  overflow: hidden;
330
- border-radius: 20px;
331
- padding: 14px;
332
  border: 1px solid rgba(255, 255, 255, 0.08);
333
  background: linear-gradient(180deg, rgba(13, 28, 44, 0.96) 0%, rgba(8, 20, 32, 0.92) 100%);
334
  box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
@@ -336,6 +550,7 @@ body.planner-interacting * {
336
 
337
  .planner-event {
338
  touch-action: none;
 
339
  }
340
 
341
  .planner-event::before,
@@ -356,14 +571,15 @@ body.planner-interacting * {
356
  }
357
 
358
  .planner-event-top strong {
359
- max-width: 72%;
360
- line-height: 1.2;
 
361
  }
362
 
363
  .planner-event-meta {
364
- margin-top: 10px;
365
  color: var(--muted);
366
- font-size: 0.82rem;
367
  }
368
 
369
  .planner-lock-badge,
@@ -387,14 +603,14 @@ body.planner-interacting * {
387
  .planner-task-clear {
388
  background: rgba(255, 107, 92, 0.14);
389
  color: #ff9388;
390
- margin-top: 10px;
391
  }
392
 
393
  .planner-event-resize {
394
  position: absolute;
395
- left: 10px;
396
- right: 10px;
397
- height: 6px;
398
  border-radius: 999px;
399
  background: linear-gradient(90deg, rgba(123, 231, 234, 0.9), rgba(97, 210, 159, 0.72));
400
  cursor: ns-resize;
@@ -402,11 +618,11 @@ body.planner-interacting * {
402
  }
403
 
404
  .planner-event-resize-top {
405
- top: 0;
406
  }
407
 
408
  .planner-event-resize-bottom {
409
- bottom: 0;
410
  }
411
 
412
  .planner-event.is-dragging,
@@ -414,9 +630,13 @@ body.planner-interacting * {
414
  opacity: 0.8;
415
  }
416
 
 
 
 
 
417
  .planner-event.is-compact {
418
  border-radius: 16px;
419
- padding: 8px 12px 10px;
420
  }
421
 
422
  .planner-event.is-compact::before {
@@ -447,7 +667,7 @@ body.planner-interacting * {
447
  }
448
 
449
  .planner-event.is-tight {
450
- padding: 6px 10px;
451
  }
452
 
453
  .planner-event.is-tight .planner-event-meta,
@@ -473,35 +693,41 @@ body.planner-interacting * {
473
  }
474
 
475
  .planner-sidebar {
476
- padding: 18px;
477
  display: grid;
478
- gap: 16px;
479
  align-content: start;
 
 
480
  }
481
 
482
  .planner-sidebar-note {
483
  display: grid;
484
- gap: 8px;
485
  color: var(--muted);
 
486
  }
487
 
488
  .planner-task-pool {
489
  display: grid;
490
- gap: 12px;
 
 
 
491
  }
492
 
493
  .planner-task-card {
494
- padding: 16px;
495
- border-radius: 22px;
496
  border: 1px solid rgba(255, 255, 255, 0.08);
497
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
498
  display: grid;
499
- gap: 12px;
500
  }
501
 
502
  .planner-task-card h4 {
503
  margin: 0;
504
- font-size: 1rem;
505
  }
506
 
507
  .planner-task-top {
@@ -515,11 +741,11 @@ body.planner-interacting * {
515
  }
516
 
517
  .planner-task-tags span {
518
- padding: 8px 10px;
519
  border-radius: 999px;
520
  background: rgba(255, 255, 255, 0.05);
521
  color: var(--muted-strong);
522
- font-size: 0.82rem;
523
  }
524
 
525
  .planner-empty,
@@ -533,9 +759,22 @@ body.planner-interacting * {
533
 
534
  .timeline-drop-preview {
535
  display: none;
 
 
536
  color: var(--muted-strong);
537
  border-style: dashed;
538
  background: rgba(123, 231, 234, 0.1);
 
 
 
 
 
 
 
 
 
 
 
539
  }
540
 
541
  .timeline-now-line {
@@ -543,6 +782,8 @@ body.planner-interacting * {
543
  height: 2px;
544
  background: linear-gradient(90deg, rgba(255, 107, 92, 0.95), rgba(255, 200, 87, 0.72));
545
  box-shadow: 0 0 18px rgba(255, 107, 92, 0.38);
 
 
546
  }
547
 
548
  .timeline-now-line::before {
@@ -578,6 +819,18 @@ body.planner-interacting * {
578
  }
579
 
580
  @media (max-width: 1180px) {
 
 
 
 
 
 
 
 
 
 
 
 
581
  .story-toolbar,
582
  .planner-head,
583
  .planner-layout {
@@ -590,6 +843,13 @@ body.planner-interacting * {
590
  justify-self: stretch;
591
  justify-content: center;
592
  }
 
 
 
 
 
 
 
593
  }
594
 
595
  @media (max-width: 840px) {
@@ -604,7 +864,7 @@ body.planner-interacting * {
604
  }
605
 
606
  .timeline-scroll {
607
- max-height: 62vh;
608
  }
609
  }
610
 
 
1
+ body {
2
+ overflow: hidden;
3
+ }
4
+
5
+ .layout.story-layout {
6
+ width: min(1560px, calc(100% - 18px));
7
+ min-height: 100vh;
8
+ padding: 14px 0;
9
+ }
10
+
11
  .story-layout {
12
+ padding-top: 0;
13
+ overflow: hidden;
14
  }
15
 
16
  .story-shell {
17
  display: grid;
18
+ gap: 12px;
19
  min-width: 0;
20
+ min-height: calc(100vh - 28px);
21
+ grid-template-rows: auto minmax(0, 1fr);
22
  }
23
 
24
  .story-toolbar {
25
+ border-radius: 24px;
26
+ padding: 12px 16px;
27
  display: grid;
28
  grid-template-columns: auto 1fr auto;
29
+ gap: 14px;
30
  align-items: center;
31
  }
32
 
 
57
  position: relative;
58
  width: 100%;
59
  overflow: hidden;
60
+ min-height: 0;
61
+ height: 100%;
62
  }
63
 
64
  .page-track {
65
  display: flex;
66
  width: 100%;
67
+ height: 100%;
68
+ align-items: stretch;
69
  transition: transform 720ms cubic-bezier(0.22, 1, 0.36, 1);
70
  will-change: transform;
71
  }
 
77
  min-width: 100%;
78
  overflow: hidden;
79
  pointer-events: none;
80
+ min-height: 0;
81
+ height: 100%;
82
  }
83
 
84
  .page-slide.is-active {
 
91
  -webkit-user-select: none !important;
92
  }
93
 
94
+ body.planner-interacting .page-home {
95
+ pointer-events: none;
96
+ }
97
+
98
+ body.planner-interacting .page-planner {
99
+ pointer-events: auto;
100
+ }
101
+
102
  .page-home {
103
  display: grid;
104
+ gap: 12px;
105
+ grid-template-rows: minmax(216px, 27vh) minmax(0, 1fr);
106
+ padding-right: 10px;
107
+ min-height: 0;
108
  }
109
 
110
  .page-planner {
111
+ padding-left: 10px;
112
+ min-height: 0;
113
  }
114
 
115
  .inline-link {
116
  border: 0;
117
  }
118
 
119
+ .page-home .hero-card {
120
+ min-height: 0;
121
+ padding: 22px 24px;
122
+ }
123
+
124
+ .clock-wrap {
125
+ gap: 6px;
126
+ padding: 6px 0 4px;
127
+ }
128
+
129
+ .clock-display {
130
+ font-size: clamp(2.7rem, 7vw, 4.9rem);
131
+ }
132
+
133
+ .clock-meta {
134
+ gap: 8px 14px;
135
+ font-size: 0.94rem;
136
+ }
137
+
138
  .hero-copy {
139
+ margin: 6px 0 0;
140
  color: var(--muted);
141
  }
142
 
143
+ .board-section {
144
+ display: grid;
145
+ gap: 12px;
146
+ grid-template-rows: auto minmax(0, 1fr);
147
+ min-height: 0;
148
+ overflow: hidden;
149
+ }
150
+
151
+ .board-section .section-header {
152
+ margin-bottom: 0;
153
+ }
154
+
155
+ .board-section .section-header h2 {
156
+ font-size: clamp(1.1rem, 1.7vw, 1.5rem);
157
+ }
158
+
159
+ .board-grid {
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
+ }
175
+
176
+ .column-header {
177
+ gap: 12px;
178
+ }
179
+
180
+ .column-title {
181
+ font-size: 1.2rem;
182
+ }
183
+
184
+ .task-list {
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 {
198
+ margin: 0 0 8px;
199
+ font-size: 0.98rem;
200
+ }
201
+
202
+ .meta-line {
203
+ gap: 6px;
204
+ }
205
+
206
+ .meta-badge {
207
+ padding: 6px 9px;
208
+ font-size: 0.76rem;
209
+ }
210
+
211
+ .progress-shell {
212
+ height: 8px;
213
+ }
214
+
215
  .planner-shell {
216
+ border-radius: 26px;
217
+ padding: 16px;
218
+ min-height: 0;
219
+ height: 100%;
220
  display: grid;
221
+ grid-template-rows: auto auto minmax(0, 1fr);
222
+ gap: 10px;
223
+ }
224
+
225
+ .planner-head h2 {
226
+ margin: 4px 0 0;
227
+ font-size: clamp(1.34rem, 1.85vw, 1.72rem);
228
+ }
229
+
230
+ .planner-head .section-note {
231
+ margin: 6px 0 0;
232
+ font-size: 0.92rem;
233
  }
234
 
235
  .planner-head,
 
247
  .planner-head,
248
  .planner-sidebar-head {
249
  justify-content: space-between;
250
+ gap: 12px;
251
  }
252
 
253
  .planner-controls {
254
+ gap: 8px;
255
  }
256
 
257
  .planner-date-picker {
258
  display: grid;
259
+ gap: 6px;
260
+ min-width: 172px;
261
  }
262
 
263
  .planner-date-picker span {
 
267
 
268
  .planner-date-picker input,
269
  .modal-form select {
270
+ min-height: 42px;
271
+ padding: 0 12px;
272
+ border-radius: 14px;
273
  border: 1px solid rgba(255, 255, 255, 0.1);
274
  background: rgba(255, 255, 255, 0.04);
275
  color: var(--text);
 
278
 
279
  .planner-meta {
280
  flex-wrap: wrap;
281
+ gap: 8px;
282
  }
283
 
284
  .planner-meta span,
 
286
  .planner-task-category {
287
  display: inline-flex;
288
  align-items: center;
289
+ padding: 6px 10px;
290
  border-radius: 999px;
291
  background: rgba(255, 255, 255, 0.06);
292
  color: var(--muted-strong);
293
+ font-size: 0.76rem;
294
  }
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,
 
309
  }
310
 
311
  .timeline-surface {
312
+ padding: 10px;
313
+ min-height: 0;
314
+ height: 100%;
315
  }
316
 
317
  .timeline-scroll {
318
+ overflow: hidden;
319
+ height: 100%;
320
+ max-height: none;
321
+ min-height: 0;
322
+ border-radius: 18px;
323
  background: rgba(3, 11, 20, 0.46);
324
  }
325
 
326
  .timeline-grid {
327
  position: relative;
328
+ min-height: 0;
329
+ height: 100%;
330
  }
331
 
332
  .timeline-axis-layer,
 
339
  bottom: 0;
340
  }
341
 
342
+ .timeline-axis-layer,
343
+ .timeline-slot-layer,
344
+ .timeline-canvas-layer,
345
+ .timeline-drop-preview,
346
+ .timeline-now-line {
347
+ bottom: auto;
348
+ }
349
+
350
  .timeline-axis-layer {
351
  left: 0;
352
  width: var(--timeline-axis-width);
 
364
  right: 12px;
365
  }
366
 
367
+ .timeline-week-header {
368
+ position: absolute;
369
+ top: 0;
370
+ display: grid;
371
+ grid-template-columns: repeat(7, minmax(0, 1fr));
372
+ gap: 6px;
373
+ z-index: 7;
374
+ }
375
+
376
+ .timeline-day-head {
377
+ min-height: 46px;
378
+ border: 1px solid rgba(255, 255, 255, 0.08);
379
+ border-radius: 16px;
380
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%);
381
+ color: var(--muted-strong);
382
+ display: grid;
383
+ place-items: center;
384
+ gap: 2px;
385
+ padding: 5px 4px;
386
+ text-align: center;
387
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
388
+ }
389
+
390
+ .timeline-day-head strong {
391
+ font-size: 0.82rem;
392
+ line-height: 1;
393
+ }
394
+
395
+ .timeline-day-head span {
396
+ font-size: 0.68rem;
397
+ color: var(--muted);
398
+ font-variant-numeric: tabular-nums;
399
+ }
400
+
401
+ .timeline-day-head.is-selected {
402
+ border-color: rgba(123, 231, 234, 0.44);
403
+ background: linear-gradient(180deg, rgba(123, 231, 234, 0.18) 0%, rgba(255, 255, 255, 0.05) 100%);
404
+ }
405
+
406
+ .timeline-day-head.is-today {
407
+ box-shadow: inset 0 0 0 1px rgba(255, 200, 87, 0.22);
408
+ }
409
+
410
+ .timeline-day-column,
411
+ .timeline-day-divider {
412
+ position: absolute;
413
+ top: 0;
414
+ bottom: 0;
415
+ pointer-events: none;
416
+ }
417
+
418
+ .timeline-day-column {
419
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.008) 100%);
420
+ }
421
+
422
+ .timeline-day-column.is-selected {
423
+ background: linear-gradient(180deg, rgba(92, 225, 230, 0.06) 0%, rgba(92, 225, 230, 0.015) 100%);
424
+ }
425
+
426
+ .timeline-day-column.is-today {
427
+ box-shadow: inset 0 0 0 1px rgba(255, 200, 87, 0.12);
428
+ }
429
+
430
+ .timeline-day-divider {
431
+ width: 1px;
432
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
433
+ }
434
+
435
  .timeline-axis-rail {
436
  position: absolute;
437
  top: 0;
 
448
  display: flex;
449
  justify-content: flex-end;
450
  align-items: center;
451
+ gap: 10px;
452
  transform: translateY(-50%);
453
+ padding-right: 20px;
454
  color: rgba(238, 244, 251, 0.76);
455
+ font-size: 0.75rem;
456
  font-variant-numeric: tabular-nums;
457
  letter-spacing: 0.02em;
458
  line-height: 1;
 
461
 
462
  .timeline-axis-tick::after {
463
  content: "";
464
+ width: 7px;
465
+ height: 7px;
466
  border-radius: 999px;
467
  background: rgba(123, 231, 234, 0.78);
468
  box-shadow: 0 0 0 4px rgba(123, 231, 234, 0.12);
 
479
 
480
  .timeline-slot-band {
481
  position: absolute;
482
+ left: 6px;
483
+ right: 8px;
484
  box-sizing: border-box;
485
  overflow: hidden;
486
+ padding: 5px 8px;
487
+ border-radius: 14px;
488
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025));
489
  border: 1px solid rgba(255, 255, 255, 0.08);
490
  display: flex;
491
  flex-direction: column;
492
  justify-content: center;
493
+ gap: 3px;
494
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
495
  }
496
 
497
  .timeline-slot-band strong {
498
+ font-size: 0.74rem;
499
  line-height: 1.1;
500
  }
501
 
502
  .timeline-slot-band span {
503
  color: var(--muted);
504
+ font-size: 0.64rem;
505
  line-height: 1.1;
506
  font-variant-numeric: tabular-nums;
507
  }
 
522
  left: 0;
523
  right: 0;
524
  box-sizing: border-box;
525
+ border-radius: 16px;
526
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.015) 100%);
527
  pointer-events: none;
528
  }
529
 
530
  .timeline-major-block span {
531
  position: absolute;
532
+ top: 10px;
533
+ right: 12px;
534
  font-family: "Sora", "Noto Sans SC", sans-serif;
535
+ font-size: clamp(1rem, 1.35vw, 1.45rem);
536
  color: rgba(238, 244, 251, 0.16);
537
  }
538
 
 
541
  position: absolute;
542
  box-sizing: border-box;
543
  overflow: hidden;
544
+ border-radius: 16px;
545
+ padding: 10px 12px 12px;
546
  border: 1px solid rgba(255, 255, 255, 0.08);
547
  background: linear-gradient(180deg, rgba(13, 28, 44, 0.96) 0%, rgba(8, 20, 32, 0.92) 100%);
548
  box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
 
550
 
551
  .planner-event {
552
  touch-action: none;
553
+ z-index: 5;
554
  }
555
 
556
  .planner-event::before,
 
571
  }
572
 
573
  .planner-event-top strong {
574
+ max-width: 100%;
575
+ line-height: 1.18;
576
+ font-size: 0.86rem;
577
  }
578
 
579
  .planner-event-meta {
580
+ margin-top: 8px;
581
  color: var(--muted);
582
+ font-size: 0.74rem;
583
  }
584
 
585
  .planner-lock-badge,
 
603
  .planner-task-clear {
604
  background: rgba(255, 107, 92, 0.14);
605
  color: #ff9388;
606
+ margin-top: 8px;
607
  }
608
 
609
  .planner-event-resize {
610
  position: absolute;
611
+ left: 8px;
612
+ right: 8px;
613
+ height: 8px;
614
  border-radius: 999px;
615
  background: linear-gradient(90deg, rgba(123, 231, 234, 0.9), rgba(97, 210, 159, 0.72));
616
  cursor: ns-resize;
 
618
  }
619
 
620
  .planner-event-resize-top {
621
+ top: -1px;
622
  }
623
 
624
  .planner-event-resize-bottom {
625
+ bottom: -1px;
626
  }
627
 
628
  .planner-event.is-dragging,
 
630
  opacity: 0.8;
631
  }
632
 
633
+ .planner-event.is-dragging {
634
+ z-index: 8;
635
+ }
636
+
637
  .planner-event.is-compact {
638
  border-radius: 16px;
639
+ padding: 7px 10px 8px;
640
  }
641
 
642
  .planner-event.is-compact::before {
 
667
  }
668
 
669
  .planner-event.is-tight {
670
+ padding: 6px 8px;
671
  }
672
 
673
  .planner-event.is-tight .planner-event-meta,
 
693
  }
694
 
695
  .planner-sidebar {
696
+ padding: 14px;
697
  display: grid;
698
+ gap: 12px;
699
  align-content: start;
700
+ min-height: 0;
701
+ grid-template-rows: auto auto minmax(0, 1fr);
702
  }
703
 
704
  .planner-sidebar-note {
705
  display: grid;
706
+ gap: 6px;
707
  color: var(--muted);
708
+ font-size: 0.84rem;
709
  }
710
 
711
  .planner-task-pool {
712
  display: grid;
713
+ gap: 10px;
714
+ min-height: 0;
715
+ overflow: auto;
716
+ padding-right: 4px;
717
  }
718
 
719
  .planner-task-card {
720
+ padding: 12px;
721
+ border-radius: 18px;
722
  border: 1px solid rgba(255, 255, 255, 0.08);
723
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
724
  display: grid;
725
+ gap: 10px;
726
  }
727
 
728
  .planner-task-card h4 {
729
  margin: 0;
730
+ font-size: 0.95rem;
731
  }
732
 
733
  .planner-task-top {
 
741
  }
742
 
743
  .planner-task-tags span {
744
+ padding: 6px 9px;
745
  border-radius: 999px;
746
  background: rgba(255, 255, 255, 0.05);
747
  color: var(--muted-strong);
748
+ font-size: 0.76rem;
749
  }
750
 
751
  .planner-empty,
 
759
 
760
  .timeline-drop-preview {
761
  display: none;
762
+ gap: 4px;
763
+ align-content: center;
764
  color: var(--muted-strong);
765
  border-style: dashed;
766
  background: rgba(123, 231, 234, 0.1);
767
+ z-index: 4;
768
+ }
769
+
770
+ .timeline-drop-preview strong {
771
+ font-size: 0.78rem;
772
+ line-height: 1.1;
773
+ }
774
+
775
+ .timeline-drop-preview span {
776
+ color: var(--muted);
777
+ font-size: 0.72rem;
778
  }
779
 
780
  .timeline-now-line {
 
782
  height: 2px;
783
  background: linear-gradient(90deg, rgba(255, 107, 92, 0.95), rgba(255, 200, 87, 0.72));
784
  box-shadow: 0 0 18px rgba(255, 107, 92, 0.38);
785
+ z-index: 6;
786
+ pointer-events: none;
787
  }
788
 
789
  .timeline-now-line::before {
 
819
  }
820
 
821
  @media (max-width: 1180px) {
822
+ body {
823
+ overflow: auto;
824
+ }
825
+
826
+ .layout.story-layout {
827
+ min-height: auto;
828
+ }
829
+
830
+ .story-shell {
831
+ min-height: auto;
832
+ }
833
+
834
  .story-toolbar,
835
  .planner-head,
836
  .planner-layout {
 
843
  justify-self: stretch;
844
  justify-content: center;
845
  }
846
+
847
+ .page-viewport,
848
+ .page-track,
849
+ .page-slide,
850
+ .planner-shell {
851
+ height: auto;
852
+ }
853
  }
854
 
855
  @media (max-width: 840px) {
 
864
  }
865
 
866
  .timeline-scroll {
867
+ height: auto;
868
  }
869
  }
870
 
templates/index.html CHANGED
@@ -14,7 +14,7 @@
14
 
15
  <nav class="story-nav" aria-label="页面导航">
16
  <button class="story-tab is-active" type="button" data-go-page="0">第一页 · 提醒板</button>
17
- <button class="story-tab" type="button" data-go-page="1">第二页 · 时间表</button>
18
  </nav>
19
 
20
  <div class="action-group">
@@ -34,9 +34,9 @@
34
  <div class="hero-topbar">
35
  <div>
36
  <p class="eyebrow">北京时间</p>
37
- <p class="hero-copy">第一页保持轻量看板视图,第二页进入时间排程。</p>
38
  </div>
39
- <button class="ghost-link inline-link" type="button" data-go-page="1">切换到时间表</button>
40
  </div>
41
  <div class="clock-wrap">
42
  <h1 class="clock-display" id="clockDisplay">00:00:00</h1>
@@ -52,11 +52,11 @@
52
  <div class="section-header">
53
  <div>
54
  <p class="section-kicker">今日待办板</p>
55
- <h2>每个分类一列,专注当前最重要的事情</h2>
56
  </div>
57
  <p class="section-note">
58
  {% if authenticated %}
59
- 已登录,可添加、勾选、重命名,并第二页拖入时间表。
60
  {% else %}
61
  当前为只读模式,登录后可进行编辑与拖拽排程。
62
  {% endif %}
@@ -124,23 +124,23 @@
124
  <section class="planner-shell card-surface">
125
  <div class="planner-head">
126
  <div>
127
- <p class="section-kicker">第二页 · 今日时间表</p>
128
- <h2>左侧安排今天,右侧拖入待办</h2>
129
- <p class="section-note" id="plannerHeadlineNote">固定课程会按学期周次自动出现,待办可拖入并自由调整时长。</p>
130
  </div>
131
  <div class="planner-controls">
132
- <button class="icon-button" id="plannerPrevDay" type="button" aria-label="">←</button>
133
  <label class="planner-date-picker">
134
- <span>选择日期</span>
135
  <input id="plannerDateInput" type="date">
136
  </label>
137
- <button class="icon-button" id="plannerNextDay" type="button" aria-label="">→</button>
138
  </div>
139
  </div>
140
 
141
  <div class="planner-meta">
142
- <span id="plannerDateLabel">{{ planner_payload.selected_date }}</span>
143
- <span id="plannerWeekday">{{ planner_payload.weekday }}</span>
144
  <span id="plannerAcademicWeek">{{ planner_payload.academic_label }}</span>
145
  <span id="plannerWindow">{{ planner_payload.settings.day_start }} - {{ planner_payload.settings.day_end }}</span>
146
  </div>
@@ -159,12 +159,12 @@
159
  <div class="planner-sidebar-head">
160
  <div>
161
  <p class="column-label">Todolist Drag Zone</p>
162
- <h3>拖动任务到左侧时间表</h3>
163
  </div>
164
  <span class="planner-count" id="plannerTaskCount">0 项</span>
165
  </div>
166
  <div class="planner-sidebar-note">
167
- <span>课程固定块,只能在后台修改。</span>
168
  <span>任务块可拖动、拉伸,也可移出排程。</span>
169
  </div>
170
  <div class="planner-task-pool" id="plannerTaskPool"></div>
 
14
 
15
  <nav class="story-nav" aria-label="页面导航">
16
  <button class="story-tab is-active" type="button" data-go-page="0">第一页 · 提醒板</button>
17
+ <button class="story-tab" type="button" data-go-page="1">第二页 · 周课表</button>
18
  </nav>
19
 
20
  <div class="action-group">
 
34
  <div class="hero-topbar">
35
  <div>
36
  <p class="eyebrow">北京时间</p>
37
+ <p class="hero-copy">第一页保持轻量提醒视图,第二页进入整周课与任务排布。</p>
38
  </div>
39
+ <button class="ghost-link inline-link" type="button" data-go-page="1">切换到周课表</button>
40
  </div>
41
  <div class="clock-wrap">
42
  <h1 class="clock-display" id="clockDisplay">00:00:00</h1>
 
52
  <div class="section-header">
53
  <div>
54
  <p class="section-kicker">今日待办板</p>
55
+ <h2>每个分类一列,压缩留白后在一屏内保持更高信息密度</h2>
56
  </div>
57
  <p class="section-note">
58
  {% if authenticated %}
59
+ 已登录,可添加、勾选、重命名,并把任务拖进第二页的周课表。
60
  {% else %}
61
  当前为只读模式,登录后可进行编辑与拖拽排程。
62
  {% endif %}
 
124
  <section class="planner-shell card-surface">
125
  <div class="planner-head">
126
  <div>
127
+ <p class="section-kicker">第二页 · 周课表</p>
128
+ <h2>按周查看课程与任务,把整周安排收进一屏</h2>
129
+ <p class="section-note" id="plannerHeadlineNote">固定课程会按学期周次自动出现,待办可拖入本周任意一天,支持上下边缘拉伸;每项任务最短 15 分钟。</p>
130
  </div>
131
  <div class="planner-controls">
132
+ <button class="icon-button planner-shift-button" id="plannerPrevDay" type="button" aria-label="">←</button>
133
  <label class="planner-date-picker">
134
+ <span>选择本周任意日期</span>
135
  <input id="plannerDateInput" type="date">
136
  </label>
137
+ <button class="icon-button planner-shift-button" id="plannerNextDay" type="button" aria-label="">→</button>
138
  </div>
139
  </div>
140
 
141
  <div class="planner-meta">
142
+ <span id="plannerDateLabel">{{ planner_payload.week_start }} - {{ planner_payload.week_end }}</span>
143
+ <span id="plannerWeekday">{{ planner_payload.week_range_label }}</span>
144
  <span id="plannerAcademicWeek">{{ planner_payload.academic_label }}</span>
145
  <span id="plannerWindow">{{ planner_payload.settings.day_start }} - {{ planner_payload.settings.day_end }}</span>
146
  </div>
 
159
  <div class="planner-sidebar-head">
160
  <div>
161
  <p class="column-label">Todolist Drag Zone</p>
162
+ <h3>把待办进本周课表</h3>
163
  </div>
164
  <span class="planner-count" id="plannerTaskCount">0 项</span>
165
  </div>
166
  <div class="planner-sidebar-note">
167
+ <span>课程固定块,只能在后台修改。</span>
168
  <span>任务块可拖动、拉伸,也可移出排程。</span>
169
  </div>
170
  <div class="planner-task-pool" id="plannerTaskPool"></div>