Files
FamilyBook/templates/home/_card_agenda.php
T
laoyang a7b3d8cfd8 v85: 首页日程刻度按结束时间定范围 / Markdown 标题真正有层级
用户报了两处(网页版):

① 「今天的日程,有一个日程到下午五点半,首页日程刻度只有九点。」
   真根因:掐头去尾只按 `time`(开始时间)算范围,`end_time` 压根没参与
   ⇒ 08:30-17:30 被当成"8 点半结束",endH = 8 + 2 = 10,
     刻度只画到 09:00;色块高 540 分钟硬塞进 3 小时格子,被 min(96, …) 夹住糊成一片。
   修法:收集「开始 + 结束」两组分钟数,endH 由 max(结束) 向上取整到小时;
   保留"区间必须包含此刻"的原逻辑与下限保护。

② 「日记 markdown 代码都没解析,## 后面的是标题,现在显示的还是和正文一样。」
   真根因:渲染一直是对的(`## x` → `<h4 class="md-h">`),问题全在视觉 ——
   `h4.md-h` 是 15px,而阅读态正文 `.note-read-block` 也是 15px,字号完全同号;
   标题只有 font-weight:500,肉眼看不出层级。
   修法:h3/h4/h5/h6 分别 18/17/15.5/14.5px,父级 .md-h 字重 600、行高 1.45,
   h6 用 muted 色;`:first-child` 去掉多余的顶部外边距。

反向验证:
  _fb_v85_agenda_range.py    13/0(钉死 08:00 做 A/B + 真实 now 复核 5 条)
  _fb_v85_md_heading.py       9/0(真实 Markdown::render + 真实 CSS + 计算值)
  _fb_v85_static_falsify.py   7/0(部署静态断言 6 处注入各自都能变红)

线上真值复核(family.yangyq.net,真实库 #1853 工程管理硕士开题 08:30~17:30):
  旧模板 刻度 07~09(3 行)下沿 10:00,色块 top=50%/h=50%  —— 复现用户所见
  新模板 刻度 07~17(11 行)下沿 18:00,色块 top=13.64%/h=81.82% = 95.46% 完整在车道内
  CSS    h4.md-h 17px / 正文 15px / .md-h font-weight 600
2026-10-24 09:31:08 +08:00

241 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 首页卡片 · 今天的日程(v62 从 templates/home.php 拆出来的,逻辑一字未改)。
*
* 用户原话(v24 第 24 批 第 3 条):「日程就显示今天的日程,也就是日视图那样形式,
* 为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,暂时不要出现
* 滚动条太丑了。为了节省上下滚动的这个空间,我们可以把没有日程的那些时间
* 掐头去尾给它去掉。…需要在当前的时间或者是时刻那里显示一条红线。」
*
* ⚠️ 高度现在由**卡片高度**决定(`.hc-h*`),不再写死 440px —— 这正是"卡片化"
* 的核心:同一份内容,用户让它多高它就多高。
*
* @var array $todayEvents 今天的事件(全天在前、其余按时刻升序)
*/
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') {
$allDay[] = $ev;
} else {
$timed[] = $ev;
}
}
// ---- 掐头去尾:只画"第一个日程前一小时"到"最后一个日程结束后一小时" ----
$startH = 0;
$endH = 24;
if ($timed) {
/* 🔴🔴 v85(用户 2026-10-24):「今天的日程,有一个日程到下午五点半,
首页日程刻度只有九点。」
真根因:这里**只按 `time`(开始时间)算范围**,`end_time` 压根没参与
⇒ 一条 08:30–17:30 的日程被当成"8 点半就结束了",
于是 `endH = 8 + 2 = 10`,刻度只画到 09:00,
色块高 540 分钟却只塞进 3 小时的格子里(被 `min(96, …)` 夹住,糊成一片)。
⚠️ 判据必须是「**结束时间也参与**」,不能只是"把 +2 改成 +12":
写死一个更大的偏移量,遇到 20:00 结束的日程照样截断,
而且会把"只有一条 9 点日程"的卡片硬拉成 12 小时高(用户嫌空)。
⚠️ `end_time` 可能是空(服务端没给)⇒ 那时按"开始 + 30 分钟"兜底,
与下面算 `hPct` 时的兜底口径**必须一致**,否则刻度与色块又会错位。 */
$agStarts = [];
$agEnds = [];
foreach ($timed as $agEv) {
$agHM0 = explode(':', (string)$agEv['time']);
$agStart = ((int)($agHM0[0] ?? 0)) * 60 + (int)($agHM0[1] ?? 0);
$agStarts[] = $agStart;
$agEnd = 0;
$agET = (string)($agEv['end_time'] ?? '');
if ($agET !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $agET, $agMM)) {
$agEnd = ((int)$agMM[1]) * 60 + ((int)$agMM[2]);
}
/* 跨天(结束 <= 开始)时按"到当天 24 点"算 —— 不能取 24:00 以上的值,
否则 $endH 被夹到 24 之后刻度会凭空多出几行。 */
if ($agEnd <= $agStart) {
$agEnd = $agStart + 30;
}
if ($agEnd > 1440) {
$agEnd = 1440;
}
$agEnds[] = $agEnd;
}
/* ⚠️ 变量名带 `ag` 前缀:这是**被 require 进来的片段**,裸赋值会撞上
宿主模板里的同名变量(流水线的"片段不许覆盖宿主变量"检查会拦下来)。 */
$startH = max(0, intdiv(min($agStarts), 60) - 1);
/* 🔴 用**结束时间**的上界(向上取整到小时)—— 这就是本条的真修法。
`+1` 之后又 `ceil`:17:30 → ceil(1050/60)=18,留半小时缓冲;再 +0(不额外留
一小时),因为卡片本来就矮,"最后一个日程结束再画一整小时空行"太浪费。 */
$endH = min(24, (int)ceil(max($agEnds) / 60));
if ($endH <= intdiv(max($agStarts), 60)) {
$endH = min(24, intdiv(max($agStarts), 60) + 1);
}
/* 🔴 v28 用户第 1 条:「今天的日程,并没有定位到红线所在的位置」。
根因:**区间里没有"现在"**,于是红线不画、定位算出来是越界值。
⇒ 让区间永远包含当前时刻(用户要的正是"一眼看到现在处在哪儿")。
🔴🔴 v44 第 3 条:首页红线"停在整点"的真根因是这里以前用 `date('G')`
(只取小时),而日程页用的是 `小时*60+分钟`。现在两处同一口径。 */
$nowHNow = (int)date('G');
if ($nowHNow < $startH) {
$startH = max(0, $nowHNow - 1);
}
if ($nowHNow >= $endH) {
$endH = min(24, $nowHNow + 2);
}
if ($endH <= $startH) {
$endH = min(24, $startH + 2);
}
}
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
$nowPos = (($nowMinutes / 60) - $startH) / max(1, ($endH - $startH));
$showNow = $nowPos >= 0 && $nowPos <= 1;
/* 冲突分列(v30 第 2/4 条):与日程页同一套算法 —— 两条撞车就左右各半。 */
usort($timed, static function (array $a, array $b): int {
return strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
});
$laneInfo = \App\Calendar::conflictLanes($timed);
foreach ($timed as $i => &$evRef) {
[$l, $w] = $laneInfo[$i] ?? [0.0, 1.0];
$evRef['left'] = round($l * 100, 4);
$evRef['width'] = round($w * 100, 4);
}
unset($evRef);
?>
<div class="hc-body hc-agenda">
<?php if (!$todayEvents): ?>
<div class="hd-pane-body">
<p class="empty-hint">今天没有安排。</p>
</div>
<?php else: ?>
<?php /* 全天日程**不在滚动容器里**(v24 第 26 批 第 2 条:要"一直显示在上面")。
为什么不用 sticky:sticky 只能粘在滚动区顶端,滚起来仍会被"标题+滚动区"
两层挤着,看起来还是在动;直接移出滚动容器是零副作用的做法。 */ ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'>
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php /* 🔴🔴 v80(用户 2026-10-22):「首页日程右侧是缺一块的,是不是左侧有刻度,
导致你算宽度的时候,没算对?」—— 用户猜对了。
⇒ 色块全部挪进这个 `.ag-lanes` 层:它左边界让出刻度(`--ag-gutter`)、
右边留 2px,于是下面那些 `left: X%` / `width: calc(Y% - 4px)`
的百分比相对的是**真正可用的宽度**。
⚠️ 之前没有这一层,百分比按"含刻度的整宽"算,色块右端比卡片**多出 44px**
被 `overflow:hidden` 裁掉 —— 那就是"缺一块"。 */
?>
<div class="ag-lanes">
<?php if ($showNow): ?>
<?php /* `data-now-min` 是给探针的零容差观测点(服务端用的分钟数),
不影响渲染 —— 见 v45 那条注释。 */ ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"
data-now-min="<?= (int)$nowMinutes ?>"></div>
<?php endif; ?>
<?php
/* 日程块:按"相对第一个刻度的小时数"定位。
⚠️ v80 起 `left` / `width` 的百分比**只相对车道宽**(`.ag-lanes`),
不再自己加 46px —— 加过一次就是"两套账混算"(见上面那段注释)。
`- 4px` 是左右各 2px 的视觉留白,与日历页 `.tg-block` 的 `- 4px` 同款。 */
foreach ($timed as $ev):
$agHM = explode(':', (string)$ev['time']);
$evMin = ((int)($agHM[0] ?? 0)) * 60 + ((int)($agHM[1] ?? 0));
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
$et2 = (string)($ev['end_time'] ?? '');
if ($et2 !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $et2, $mm2)) {
$endMin = ((int)$mm2[1]) * 60 + ((int)$mm2[2]);
} else {
$endMin = $evMin + 30;
}
if ($endMin <= $evMin) { $endMin = $evMin + 30; }
$endMin = min($endMin, $endH * 60);
$hPct = ($endMin / 60 - $startH) / max(1, ($endH - $startH)) * 100 - $topPct;
$hPct = max(2.2, $hPct);
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= number_format($topPct, 2) ?>%;
height: <?= number_format($hPct, 2) ?>%;
left: calc(<?= (float)($ev['left'] ?? 0) ?>% + 2px);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
title="<?= e((string)$ev['title']) ?>"
data-cal="<?= (int)$ev['cal'] ?>">
<?php /* 🔴 v63(用户 2026-10-19 第 2 条):「首页日程显示,把所属的日历本,
放在色块的右侧。」⇒ 时间与标题包进 `.ag-ev-main`(占据剩余宽度、可收缩),
日历本作为它右边的**兄弟**。
⚠️ 原来是「时间 float:left + 标题 block + 日历本 block」三板斧:日历本
`display:block` 必然被顶到第二行,色块一矮就完全看不见。改成弹性行
后,日历本与标题同一行、永远在右。 */ ?>
<span class="ag-ev-main">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
</span>
<span class="ag-ev-cal"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($todayEvents): ?>
<script>
/* 进场定位到"现在"(v24 第 26 批 第 2 条):让红线落在可视区**正中**。
⚠️ 用 scrollTop 直接赋值,**不要** scrollIntoView({block:'center'}) ——
后者会把整个浏览器窗口也滚下去,一进首页就被拽到页面中部。
⚠️ 只判"有没有网格可滚",别判"有没有分时段日程"(v29 踩过:只有全天日程时
脚本整段不输出,而网格和红线照样渲染 ⇒ 红线在、页面停在最上面)。 */
(function () {
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]')
|| document.querySelector('[data-ag-scroll]');
if (!box) { return; }
var line = box.querySelector('.ag-now');
var RATIO = <?= json_encode(round($nowPos, 6)) ?>;
function targetOffset() {
if (line) { return line.offsetTop; }
var grid = box.querySelector('.ag-grid');
return grid ? grid.offsetHeight * RATIO : 0;
}
function centerOnNow() {
box.scrollTop = Math.max(0, targetOffset() - (box.clientHeight / 2));
}
centerOnNow();
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { requestAnimationFrame(centerOnNow); });
}
})();
</script>
<?php endif; ?>