Files
FamilyBook/app/Calendar.php
T
laoyang ef574d82a4 v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
2026-10-15 20:19:09 +08:00

1661 lines
82 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
/**
* 日历 / 日程(v14 新模块,导航条上叫「日程」)。
*
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** ——
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
*
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务,
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
*/
declare(strict_types=1);
namespace App;
final class Calendar
{
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
public const ATTACH = 'calendar_event';
/** 系统预置的默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom')
*
* v24 第 10 批 #4:加了两个**不可删除的只读本** ——
* · lunar 农历(每天的农历日期 + 农历节日),由 Holiday::lunarRange 派生;
* · holiday 节假日(法定假日的**休息与调休**),由 Holiday::year 派生,
* 并且**自动更新**(官方一公布就换用官方数据,见 Holiday 的类注释)。
* 它们和 movie/trip 一样是"从别处派生、不落 calendar_events"的视图本,
* 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。
*/
public const DEFAULT_KINDS = [
/* 🔴 v24 第 21 批 第 7 条:**每人必须有"我自己的日程本"** —— 原来这里
只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/节气),
一本可写的普通本都没有。后果不只是"列表里缺一本":
· 新建日程的"放在哪本"里全是派生本,默认选中排最前的**观影** →
用户直接打字点"加上",建出来的其实是一条**观影记录**(不是日程)。
这是"建错东西",事后极难发现(日历里看着是多了条日程,
可观影列表里莫名多了一条记录);
· 想建一条真正的日程得先自己去"日历本"页建一本 —— 而用户根本没被告知。
所以把"我的日程"补进默认本里(kind='custom' = 可写)。
⚠️ 幂等:seedDefaults 按 `owner_id + kind + name` 去重,
老用户升级时(Schema 里会跑 seedDefaultsForFamily)补一本,不会重复建。
⚠️ 顺序:`mine()` 是 `ORDER BY kind='custom', position, id` ——
custom 排最前,所以它天然就是"新建日程"的默认本。 */
'custom' => ['我的日程', '#4A7C82'],
'movie' => ['观影', '#7A4E8C'],
'trip' => ['旅行', '#3F7D5A'],
'lunar' => ['农历', '#8C6D3F'],
'holiday' => ['节假日', '#B0342C'],
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
// 系统日历本,用来存放24节气。」)—— 两个都由 Holiday 派生、都不可删。
'traditional' => ['传统节日', '#A6305C'],
'solarterm' => ['二十四节气', '#307AA6'],
];
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
/**
* **纯派生、完全不能写入**的日历本:内容按日期算出来(农历/节假日/传统节日/节气)。
* 往这些本子里加日程毫无意义 —— 加了也不会显示(Calendar::month 只从
* kind='custom' 取 calendar_events),只会留下一堆"看不见的日程"。
*
* 🔴 v24 第 21 批 第 7 条:这里原来把 `movie` / `trip` 也算进来了,
* 而 `calendar/event_add` 里**紧跟着**就有两段专门处理它们的分支
* (「已加进观影」/「已加进旅程」)—— 那两段于是**永远走不到**:
* 校验先一步把请求打回去了。用户的日历本选择器里明明列着「观影」和「旅行」
* (它们是可写的:加一条 = 建一条真正的观影/旅程记录),
* 选中却必定失败 —— 这就是"选了观影本、点了加上、只得到一句错误"的根因。
*
* ⚠️ 判据要按**能不能写**来分,不是按"是不是派生":
* · lunar / holiday / traditional / solarterm —— 纯算出来,只读;
* · movie / trip —— 也是派生(内容来自观影/旅程表),但**通过各自的
* 业务路径可写**(日历这边只是把"加一条"转成"建一条记录")。
*/
public const READONLY_KINDS = ['lunar', 'holiday', 'traditional', 'solarterm'];
/** 这本日历的内容是不是"自动生成、不能直接往里写日程" */
public static function isReadonlyKind(string $kind): bool
{
return in_array($kind, self::READONLY_KINDS, true);
}
/**
* 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。
*
* ⚠️ 一律挑**中低饱和**的:月网格里日程条是**白字压在色块上**,
* 太浅看不清字,太艳会盖过整个月视图。
*/
public const PRESET_CAL_COLORS = [
'#A63030', '#A65C30', '#A68930', '#97A630',
'#6BA630', '#3FA630', '#30A64E', '#30A67A',
'#30A6A6', '#307AA6', '#304EA6', '#3F30A6',
'#6B30A6', '#9730A6', '#A63089', '#A6305C',
];
/**
* 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。
*
* ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排:
* 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的,
* 按数值排也一样,但一改动顺序用户就会以为自己选错了。
*/
public const REMINDERS = [
5 => '提前 5 分钟',
10 => '提前 10 分钟',
15 => '提前 15 分钟',
30 => '提前 30 分钟',
60 => '提前 1 小时',
120 => '提前 2 小时',
240 => '提前 4 小时',
480 => '提前 8 小时',
720 => '提前 12 小时',
1440 => '提前 1 天',
];
/** 新建一本 */
public static function create(int $familyId, int $ownerId, string $name, string $color, string $kind = 'custom'): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO calendars (family_id, owner_id, name, color, kind, position) VALUES (?,?,?,?,?,?)'
)->execute([
$familyId, $ownerId, mb_substr(trim($name), 0, 50, 'UTF-8'),
// ⚠️ 统一**大写** hex:normalize() 会小写化,而我读回来的颜色要跟
// 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的,
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
// ⚠️ 别写成 `'kind' => …`:这是个**位置参数**数组(对应 6 个 ? 占位符),
// 混一个命名键进去会让 PDO 报 `Invalid parameter number` ——
// 而且是**静默的**:整个 seedDefaults 抛异常,日历本一本都建不出来。
isset(self::DEFAULT_KINDS[$kind]) || $kind === 'custom' ? $kind : 'custom',
self::nextPosition($ownerId),
]);
return (int)$db->lastInsertId();
}
public static function update(int $id, string $name, string $color): void
{
Database::get()->prepare('UPDATE calendars SET name = ?, color = ? WHERE id = ?')
->execute([
mb_substr(trim($name), 0, 50, 'UTF-8'),
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
$id,
]);
}
/** 删一本(默认本不许删 —— 删了观影/旅行就再也回不来了) */
public static function delete(int $id): void
{
Database::get()->prepare("DELETE FROM calendars WHERE id = ? AND kind = 'custom'")->execute([$id]);
}
public static function find(int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM calendars WHERE id = ?');
$st->execute([$id]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 我能看到的日历本 = 我自己的 + 别人共享给我的(带上我对它的权限)。
*
* `perm` 的含义:'owner'(我的,想怎么改都行)/ 'write' / 'read'。
*/
public static function mine(int $familyId, int $personId): array
{
// ⚠️ 共享给我的本子也要带上**主人是谁**(页面要显示"谁共享给我的"),
// 而且按"我看他叫什么"显示(owner_nick)—— 用户第 3 条。
$st = Database::get()->prepare(
"SELECT c.*, 'owner' AS perm, 0 AS shared,
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
n.nickname AS owner_nick
FROM calendars c
JOIN persons p ON p.id = c.owner_id
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
WHERE c.family_id = ? AND c.owner_id = ?
UNION ALL
SELECT c.*, s.perm AS perm, 1 AS shared,
p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color,
n.nickname AS owner_nick
FROM calendar_shares s
JOIN calendars c ON c.id = s.calendar_id
JOIN persons p ON p.id = c.owner_id
LEFT JOIN nicknames n ON n.target_id = c.owner_id AND n.viewer_id = ?
WHERE s.person_id = ? AND c.family_id = ?
ORDER BY kind = 'custom', position, id"
);
$st->execute([$personId, $familyId, $personId, $personId, $personId, $familyId]);
return $st->fetchAll();
}
/** 这本日历我能写吗(owner 或共享为 write) */
public static function canWrite(array $cal, int $personId): bool
{
if ((int)$cal['owner_id'] === $personId) {
return true;
}
return ($cal['perm'] ?? '') === 'write';
}
// ---------------- 我的日程显示偏好(v24 用户 2026-10-03 第 4、5 条)----------------
/** 三种视图(用户第 4 条:设置里可选默认) */
public const VIEWS = ['month' => '月', 'week' => '周', 'day' => '天'];
/**
* 打开日程页默认看哪个视图(每人一份)。
*
* 用户原话:「在日程的设置中,可以设置默认显示的视图模式,月视图还是周视图还是日视图。」
*
* ⚠️ 只在用户**没显式指定 view** 时才用它 —— 手点了「周」「月」按钮之后,
* 那个选择必须优先,否则每次翻页都被默认值拽回去,等于按钮点不动。
*/
public static function defaultView(int $personId): string
{
$v = (string)UserSetting::get($personId, 'cal_view', '');
return isset(self::VIEWS[$v]) ? $v : 'month';
}
/** 保存默认视图(非法值当没设过,回落 month) */
public static function saveDefaultView(int $personId, string $v): void
{
UserSetting::set($personId, 'cal_view', isset(self::VIEWS[$v]) ? $v : 'month');
}
/**
* 我在日历页上**显式隐藏了**哪几本(每人一份,存本子 id 的 JSON 数组)。
*
* ⚠️ 存"关掉的"而不是"打开的":本子会不断新建(也可能别人共享给我),
* 存"打开的"会导致**新本子默认不显示** —— 用户会以为"共享了但还是看不到"。
* 存"关掉的",新本子天然是显示的,符合直觉。
*
* @return array<int,bool> 本子 id => true
*/
public static function hiddenCalendars(int $personId): array
{
$raw = (string)UserSetting::get($personId, 'cal_hidden', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
$out = [];
if (is_array($dec)) {
foreach ($dec as $id) {
$id = (int)$id;
if ($id > 0) {
$out[$id] = true;
}
}
}
return $out;
}
public static function saveHiddenCalendars(int $personId, array $ids): void
{
$out = [];
foreach ($ids as $id) {
$id = (int)$id;
if ($id > 0 && !in_array($id, $out, true)) {
$out[] = $id;
}
}
UserSetting::set($personId, 'cal_hidden', json_encode($out));
}
/**
* 按 id 取一本「我能看到的」日历(带 perm / shared)。
*
* ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars
* 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写,
* 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。
*/
public static function mineOne(int $familyId, int $personId, int $calendarId): ?array
{
foreach (self::mine($familyId, $personId) as $c) {
if ((int)$c['id'] === $calendarId) {
return $c;
}
}
return null;
}
/** 给某人补建两本默认日历(幂等) */
public static function seedDefaults(int $familyId, int $personId): void
{
foreach (self::DEFAULT_KINDS as $kind => list($name, $color)) {
$st = Database::get()->prepare(
'SELECT id FROM calendars WHERE owner_id = ? AND kind = ? AND name = ?'
);
$st->execute([$personId, $kind, $name]);
if ($st->fetchColumn() === false) {
self::create($familyId, $personId, $name, $color, $kind);
}
}
}
/** 升级 / 安装时给全家每个账号补一遍(老库也要有这些本) */
public static function seedDefaultsForFamily(int $familyId): void
{
$st = Database::get()->prepare(
"SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id
WHERE p.family_id = ? AND a.status = 'active'"
);
$st->execute([$familyId]);
foreach ($st->fetchAll() as $r) {
self::seedDefaults($familyId, (int)$r['id']);
}
}
/**
* 补一本「我的日程」(kind='custom')—— 每个人都要有。
*
* 🔴 v24 第 21 批 第 7 条:`DEFAULT_KINDS` 里原来只有 6 本**派生**本,
* 于是"新建日程 → 放在哪本"全是派生本、默认还选中排最前的**观影** ——
* 用户直接打字点「加上」,建出来的是**观影记录**("建错东西",
* 事后极难发现)。所以默认清单里补了 'custom' => 「我的日程」。
*
* ⚠️ **但只补清单是不够的**:`seedDefaults()` 只在**安装**与**结构升级**
* 那两条路径上被调用,而线上这套库早就升过级了 → 老用户**永远拿不到**这一本
* (实测线上:owner=1 可写的普通本 0 本,唯一那本 custom 是用户自己建的)。
* 所以必须有这条"按需补"的路径,由**打开日历时**顺手调一次(见 calendar 路由)。
*
* ⚠️ 幂等:`owner_id + kind='custom'` 只要有一本就不再补 —— 名字**不计入判据**,
* 否则用户把自己的本子改名成别的之后,会又冒出一本「我的日程」
* (`seedDefaults()` 是按 `owner_id + kind + name` 判重的,这里刻意更宽)。
* ⚠️ 一次请求只跑一条 `SELECT`(有就不再写),对打开日历的开销可忽略。
*/
public static function ensurePersonalBook(int $familyId, int $personId): void
{
if ($familyId <= 0 || $personId <= 0) {
return;
}
$st = Database::get()->prepare(
"SELECT id FROM calendars WHERE owner_id = ? AND kind = 'custom' LIMIT 1"
);
$st->execute([$personId]);
if ($st->fetchColumn() !== false) {
return; // 已经有一本可写的普通本了(不管它叫什么名字)
}
[$name, $color] = self::DEFAULT_KINDS['custom'];
self::create($familyId, $personId, $name, $color, 'custom');
}
// ---------------- 共享 ----------------
public static function shares(int $calendarId): array
{
$st = Database::get()->prepare(
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
FROM calendar_shares s JOIN persons p ON p.id = s.person_id
WHERE s.calendar_id = ? ORDER BY p.id'
);
$st->execute([$calendarId]);
return $st->fetchAll();
}
public static function setShare(int $calendarId, int $personId, string $perm): void
{
Database::get()->prepare(
'INSERT INTO calendar_shares (calendar_id, person_id, perm) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
)->execute([$calendarId, $personId, $perm === 'write' ? 'write' : 'read']);
}
public static function dropShare(int $calendarId, int $personId): void
{
Database::get()->prepare('DELETE FROM calendar_shares WHERE calendar_id = ? AND person_id = ?')
->execute([$calendarId, $personId]);
}
// ---------------- 日程 ----------------
/**
* 新建一条日程。
*
* ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成
* "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15",
* 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。
* ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程,
* 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。
*/
public static function addEvent(int $calendarId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO calendar_events
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
reminder_minutes, status, transp, rrule, note, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$calendarId,
self::newUid(),
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
self::textOrNull((string)($d['location'] ?? ''), 255),
$d['starts_at'],
$d['ends_at'] ?: null,
!empty($d['all_day']) ? 1 : 0,
self::reminderMinutes($d['reminder_minutes'] ?? null),
self::statusOf($d['status'] ?? null),
!empty($d['transp']) ? 1 : 0,
self::textOrNull((string)($d['rrule'] ?? ''), 255),
self::textOrNull($d['note'] ?? ''),
$d['created_by'] ?: null,
]);
return (int)$db->lastInsertId();
}
/**
* 改一条日程。
*
* ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。
* 不加的话,改完日程客户端可能还抱着旧的不放。
*/
public static function updateEvent(int $id, array $d): void
{
$sets = 'title=?, location=?, starts_at=?, ends_at=?, all_day=?,
reminder_minutes=?, status=?, transp=?, rrule=?, note=?';
$args = [
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
self::textOrNull((string)($d['location'] ?? ''), 255),
$d['starts_at'],
$d['ends_at'] ?: null,
!empty($d['all_day']) ? 1 : 0,
self::reminderMinutes($d['reminder_minutes'] ?? null),
self::statusOf($d['status'] ?? null),
!empty($d['transp']) ? 1 : 0,
self::textOrNull((string)($d['rrule'] ?? ''), 255),
self::textOrNull($d['note'] ?? ''),
];
/* ⚠️ `extra`(未建模的 VEVENT 属性)**只在调用方显式给了才写**。
网页的编辑表单不认识这个字段 —— 无条件写 NULL 的话,用户在网页上
改一次标题,CalDAV 客户端写进来的 `X-*` 就被悄悄抹掉了。
「没说」=「别动」,这条对往返保真很关键(与 reminder_minutes 不同:
那一个表单一定会给,所以照旧无条件写)。 */
if (array_key_exists('extra', $d)) {
$sets .= ', extra=?';
$args[] = self::textOrNull((string)$d['extra']);
}
$sets .= ', sequence = sequence + 1';
$args[] = $id;
Database::get()->prepare("UPDATE calendar_events SET {$sets} WHERE id=?")->execute($args);
}
public static function deleteEvent(int $id): void
{
Attachment::removeAll(self::ATTACH, $id);
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
}
/** 一条日程(含所属本子;用来判权限) */
public static function event(int $id): ?array
{
$st = Database::get()->prepare(
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ?'
);
$st->execute([$id]);
$r = $st->fetch();
return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行)
}
/**
* 这条日程我能写吗(按它所在本子的归属与共享权限判)。
*
* ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite():
* canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去
* 会因为没有 perm 而恒判成不能写。
*/
public static function canWriteEvent(array $event, int $personId): bool
{
if ((int)($event['owner_id'] ?? 0) === $personId) {
return true;
}
$st = Database::get()->prepare(
'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?'
);
$st->execute([(int)$event['calendar_id'], $personId]);
return (string)$st->fetchColumn() === 'write';
}
// ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)----------------
/** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */
public static function skipOnce(int $eventId, string $dateYmd): void
{
$d = substr(trim($dateYmd), 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
return;
}
Database::get()->prepare(
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)
ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)'
)->execute([$eventId, $d]);
}
/** 取消"跳过"标记(撤销一次取消) */
public static function unskipOnce(int $eventId, string $dateYmd): void
{
Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?')
->execute([$eventId, substr(trim($dateYmd), 0, 10)]);
}
/** 这条日程被跳过了哪些日期 */
public static function skipsOf(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date'
);
$st->execute([$eventId]);
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
}
/**
* 把这条日程的「例外日」**整组替换**成给定的几天(CalDAV PUT 的权威语义)。
*
* ⚠️ 为什么是"整组替换"而不是"逐个增删":CalDAV 的 PUT 语义是
* **客户端推上来的整份资源就是这条日程的全部真相**(RFC 4791 §5.3.2)。
* 只增不减的话,用户在客户端把某次"恢复"回来,服务器上那条排除记录
* 永远删不掉 —— 表现为"取消了又改回来,可它还是不出现"。
* ⚠️ 传空数组 = 清空(把之前排除的全恢复)。
*/
public static function setSkips(int $eventId, array $datesYmd): void
{
$db = Database::get();
$clean = [];
foreach ($datesYmd as $d) {
$d = substr(trim((string)$d), 0, 10);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
$clean[$d] = true;
}
}
$db->beginTransaction();
try {
$db->prepare('DELETE FROM calendar_event_skips WHERE event_id = ?')->execute([$eventId]);
if ($clean) {
$ins = $db->prepare(
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)'
);
foreach (array_keys($clean) as $d) {
$ins->execute([$eventId, $d]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
/**
* `RECURRENCE-ID`(ICS 形态:`20261021T090000` / `20261021`)→ `Y-m-d`。
*
* 用来回答"这个覆盖行替换的是哪一天的发生",也是网页日历把覆盖行
* 挪到新日期去画时的对齐依据。解析不出来就返回空串(调用方跳过)。
*
* ⚠️ 传进来的值必须已经是**本地浮动**形态(`Ics::parse()` 出来的
* `recurrence_id` 就是这样),否则带 `Z`/`TZID` 的值会被截到**前一天**。
* `public` 是因为 `dav.php` 也要用它把"取消某一次"折算成 EXDATE。
*/
public static function ridDay(string $rid): string
{
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', trim($rid), $m)) {
return $m[1] . '-' . $m[2] . '-' . $m[3];
}
return '';
}
/**
* 一条日程的**覆盖行**(重复系列里被改到别的时间的某几次)。
*
* @return array<int,array> 按 recurrence_id 升序
*/
public static function overridesOf(int $eventId): array
{
$st = Database::get()->prepare(
'SELECT * FROM calendar_event_overrides WHERE event_id = ? ORDER BY recurrence_id'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
/**
* 一批日程的覆盖行(`[event_id => [行…]]`)—— 导出/资源列表用。
*
* ⚠️ 单独提供一个批量入口是为了**避免 N+1**:一本日历几百条日程时,
* 逐条查一次覆盖行会让 REPORT 从"一次查询"变成"几百次"。
*
* @param array<int,int|string> $eventIds
*/
public static function overridesFor(array $eventIds): array
{
$ids = [];
foreach ($eventIds as $i) {
$i = (int)$i;
if ($i > 0) {
$ids[$i] = true;
}
}
if (!$ids) {
return [];
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT * FROM calendar_event_overrides WHERE event_id IN ($in) ORDER BY event_id, recurrence_id"
);
$st->execute(array_keys($ids));
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['event_id']][] = $r;
}
return $out;
}
/**
* 把一条日程的覆盖行**整组替换**(CalDAV PUT 的权威语义)。
*
* @param array<int,array> $rows 每行至少要有 recurrence_id / starts_at;
* title 为空时回落到"(无标题日程)"
*/
public static function replaceOverrides(int $eventId, array $rows): void
{
$db = Database::get();
$db->beginTransaction();
try {
$db->prepare('DELETE FROM calendar_event_overrides WHERE event_id = ?')->execute([$eventId]);
if ($rows) {
$ins = $db->prepare(
'INSERT INTO calendar_event_overrides
(event_id, recurrence_id, title, location, starts_at, ends_at, all_day,
note, status, sequence, extra)
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
);
foreach ($rows as $r) {
$rid = trim((string)($r['recurrence_id'] ?? ''));
// 没有 RECURRENCE-ID 的行不是覆盖行(那应该是主事件)——跳过,
// 免得把一条普通日程塞成"替换第 0 次"这种说不通的东西。
if ($rid === '' || empty($r['starts_at'])) {
continue;
}
$title = trim((string)($r['title'] ?? ''));
$ins->execute([
$eventId,
mb_substr($rid, 0, 64, 'UTF-8'),
mb_substr($title !== '' ? $title : '(无标题日程)', 0, 200, 'UTF-8'),
self::textOrNull((string)($r['location'] ?? ''), 255),
(string)$r['starts_at'],
($r['ends_at'] ?? null) ?: null,
!empty($r['all_day']) ? 1 : 0,
self::textOrNull((string)($r['note'] ?? '')),
self::statusOf($r['status'] ?? null),
(int)($r['sequence'] ?? 0),
self::textOrNull((string)($r['extra'] ?? '')),
]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
/**
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
*
* 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义,
* 「仅此一次」的改动不能碰它。所以:
* ① 在原系列里把这一天标记为跳过(不再出现原样那次);
* ② 新建一条只发生这一次的日程,内容 = 用户改后的。
* 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。
*
* @return int 新建的那条日程 id
*/
public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int
{
$date = substr(trim($date), 0, 10);
self::skipOnce((int)$ev['id'], $date);
// 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date
$starts = (string)($d['starts_at'] ?? '');
$ends = (string)($d['ends_at'] ?? '');
$startDay = substr($starts, 0, 10);
if ($startDay !== $date) {
// 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date,
// 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。
$starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00');
}
$ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null;
$new = $d;
$new['starts_at'] = $starts;
$new['ends_at'] = $ends;
$new['rrule'] = ''; // 单次日程不带重复规则
// ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略,
// 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。
$new['created_by'] = $byId ?: ($d['created_by'] ?? null);
return self::addEvent((int)$ev['calendar_id'], $new);
}
/** 一条日程的 UID(CalDAV 那边按 UID 取) */
public static function newUid(): string
{
return bin2hex(random_bytes(12)) . '@familybook';
}
/**
* 导出 ics 用的行集(用户 2026-10-02 第 7 条)。
*
* 三本日历的内容来源不一样,但**导出时都要有东西**:
* · custom → calendar_events 里真存着的自定义日程;
* · movie → events(kind=movie) 派生;
* · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。
* 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...),
* Ics 那边就不必分辨来源是哪种。
*
* @return array<int,array>
*/
public static function exportRows(array $cal, int $familyId, int $me): array
{
$kind = (string)$cal['kind'];
if ($kind === 'movie' || $kind === 'trip') {
// 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行
$from = '1970-01-01';
$to = '2100-01-01';
$out = [];
$gens = []; // key => 同一个节点铺出来的各天(v52:要合成一条跨天行,见下)
$want = (int)$cal['id'];
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
// **不带 kind 判断**的(它们本就不属于任何日历本,`cal` 给的是 0)。
// 于是"取观影本"也会把全家 130 年的生日 + 所有任务一起带出来 ——
// 实测线上 /dav/1.ics/ 的 2981 条里,绝大多数是 anniv(生日)。
// ⇒ 导出必须只认**真正属于这一本**的条目。
// ⚠️ 判据是 `cal` 相等,不是 `kind` 白名单:白名单每加一个
// 系统日历本就要改一次,漏改就是"这本里混进别人"。
if ((int)($e['cal'] ?? 0) !== $want) {
continue;
}
/* ⚠️ 分组键**不能含日期**:跨天条目会被 `month()` 逐天铺出来,
含日期就各成一组(每天一条 VEVENT、UID 还相同)——实测那个 bug 的
前半段就是这样:跨天住宿出 3 条,被 resources() 按 UID 去重后
只留**第一天**那条,而第一天那条没有结束时刻 ⇒ 又变成 DTEND=DTSTART。
所以键必须是"这一个节点"的标识:kind + ref_id。 */
$key = $e['kind'] . '-' . (int)$e['ref_id'];
/* 🔴🔴 v52(用户 2026-10-15 第 1 条):「旅程里面的行程,通过 CalDAV 同步了之后,
时间是错的……它只有开始时间,**结束时间和开始时间是一样的**。」
真根因:这里对**派生条目**(观影 / 旅程)一律写死 `'ends_at' => null`,
而 `Ics::vevent()` 遇到空的结束时刻会退化成「`DTEND` = `DTSTART`」
(那行注释写的是"没有结束时刻就按开始时刻算,给一个等于开始的 DTEND 最稳")
⇒ 同步到手机 / 雷鸟上,每条行程的结束时间都等于开始时间。
⚠️ 修法要点:`month()` 把**跨天条目逐天**铺出来(一天一条),
每条各自带/不带 `end_time`(结束时刻**只在最后一天**给,
与自定义日程同一口径)。所以**不能**在这里"第一天直接用 end_time"——
那会把结束时刻贴到开始那天上。
⇒ 改为:**同一个节点(同 key)先收集它铺出来的所有天**,
再用"第一天的时间 + 最后一天的时间"拼成**一条**跨天行。
这样才得到真正的 `DTSTART → DTEND` 跨度。
⚠️ `uid` 仍是 `kind-ref_id@familybook`(不含日期)—— CalDAV 资源按 UID 去重,
UID 带日期会让一个节点变成 N 个资源(参见 CalDav::resources 的说明)。 */
if (!isset($gens[$key])) {
$gens[$key] = [];
}
$gens[$key][] = $e;
}
foreach ($gens as $key => $occ) {
// 按日期排序,取首末两天
usort($occ, static function (array $a, array $b): int {
return strcmp((string)$a['date'], (string)$b['date']);
});
$firstDay = (string)$occ[0]['date'];
$lastDay = (string)$occ[count($occ) - 1]['date'];
$firstT = (string)($occ[0]['time'] ?? ''); // 'HH:MM' 或 ''
$allDay = empty($firstT);
$starts = $firstDay . ' ' . ($allDay ? '00:00:00' : $firstT . ':00');
/* 结束时刻:从**最后一天**那一条里取 `end_time`(`month()` 只在末天给)。
⚠️ 末天那条若也没有 end_time(本来就没填结束)⇒ 留 null,
退回"DTEND = DTSTART"的旧行为(服务端渲染那边也是"没填结束")。
⚠️ 结束若不在开始之后(数据倒挂)⇒ 也留 null,别导出负时长。 */
$lastT = (string)($occ[count($occ) - 1]['end_time'] ?? '');
$ends = null;
if ($lastT !== '') {
$ends = $lastDay . ' ' . $lastT . ':00';
if ($ends <= $starts) {
$ends = null;
}
}
$e = $occ[0];
$out[] = [
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
'title' => (string)$e['title'],
'starts_at' => $starts,
'ends_at' => $ends,
'all_day' => $allDay ? 1 : 0,
'location' => null,
'note' => null,
'status' => 'confirmed',
'transp' => 0,
'sequence' => 0,
'created_at' => null,
'updated_at' => null,
'reminder_minutes' => null,
'rrule' => null,
];
}
return $out;
}
$st = Database::get()->prepare(
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
);
$st->execute([(int)$cal['id']]);
// 🔴 v31:系统日历本(农历 / 节假日 / 传统节日 / 节气)原来是**空的** ——
// exportRows 只认 custom/movie/trip,于是手机上把这几个本订阅进来
// 里面一条都没有,用户会以为"同步坏了"。
// 它们的内容是纯日期推导(Holiday::*),没有 calendar_events 行,只能派生。
//
// ⚠️ 范围必须**封顶**,不能像派生本那样 1970→2100:
// · 农历是**每天一条** —— 130 年 ≈ 47500 条,单个 multistatus 会有十几 MB;
// · 客户端订阅的也是"现在前后若干年"。
// 给 [今年-1, 今年+5]:往前一年覆盖"今年年初要看的去年腊月",往后五年
// 够手机翻日历用,总量(农历 ≈ 2200 条)与真实使用相符。
if ($kind !== 'custom') {
$y0 = (int)date('Y') - 1;
$y1 = (int)date('Y') + 5;
$want = (int)$cal['id'];
$out = [];
foreach (self::month($familyId, [$cal], $me, $y0 . '-01-01', $y1 . '-12-31') as $e) {
// 只认属于这一本的(`month()` 里任务/纪念日那两段不带 kind 判断,
// 给任意 cal 都会一起出来 —— 见上面派生本那段注释)
if ((int)($e['cal'] ?? 0) !== $want) {
continue;
}
$day = (string)$e['date'];
// ⚠️ UID 必须**带日期**:这些条目 ref_id 恒为 0,
// 照搬 `kind-ref_id@familybook` 会让整本共用一个 UID,
// 按 UID 去重后就只剩一天(见 CalDav::resources)。
$out[] = [
'uid' => (string)$e['kind'] . '-' . $day . '@familybook',
'title' => (string)$e['title'],
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
'ends_at' => null,
'all_day' => 1,
'location' => null,
'note' => null,
'status' => 'confirmed',
'transp' => 0,
'sequence' => 0,
'created_at' => null,
'updated_at' => null,
'reminder_minutes' => null,
'rrule' => null,
];
}
return $out;
}
/* ---- 把重复系列的「例外」附着到对应主事件上(CalDAV 导出用)----
🔴 v52b(2026-10-15):`Ics::export()` 需要这两样才能把正文写对 ——
`_skip_dates`(EXDATE:哪几次被取消)与 `_overrides`(RECURRENCE-ID 覆盖行)。
两样都是**批量查**的(`overridesFor` / 一次 IN 查询),不能逐条查:
一本几百条日程时逐条查会让 REPORT 慢一个数量级。
⚠️ 只给自定义本挂:派生日历(农历/节假日/观影/旅程)没有 RRULE,
也就没有"某一次"可例外(Ics 那边也会因为没有 RRULE 而跳过 EXDATE)。 */
$rows = $st->fetchAll();
if ($rows) {
$ids = array_map('intval', array_column($rows, 'id'));
$in = implode(',', array_fill(0, count($ids), '?'));
$sk = Database::get()->prepare(
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in)"
);
$sk->execute($ids);
$skips = [];
foreach ($sk->fetchAll() as $r) {
$skips[(int)$r['event_id']][] = (string)$r['skip_date'];
}
$ovr = self::overridesFor($ids);
foreach ($rows as $i => $r) {
$eid = (int)$r['id'];
$rows[$i]['_skip_dates'] = $skips[$eid] ?? [];
$rows[$i]['_overrides'] = $ovr[$eid] ?? [];
}
}
return $rows;
}
/**
* 导入一条 ics 里的 VEVENT。
*
* ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 ——
* 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作
* "导一次没看清,再导一次"会立刻翻车)。
*
* @return string 'added' | 'updated'
*/
public static function importEvent(int $calendarId, array $v, int $createdBy): string
{
$db = Database::get();
$uid = (string)($v['uid'] ?? '');
if ($uid !== '') {
$st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?');
$st->execute([$calendarId, $uid]);
$old = (int)$st->fetchColumn();
if ($old > 0) {
self::updateEvent($old, $v);
return 'updated';
}
} else {
$uid = self::newUid();
}
$db->prepare(
'INSERT INTO calendar_events
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
reminder_minutes, status, transp, rrule, note, created_by, extra)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$calendarId,
$uid,
mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'),
self::textOrNull((string)($v['location'] ?? ''), 255),
$v['starts_at'],
$v['ends_at'] ?: null,
!empty($v['all_day']) ? 1 : 0,
self::reminderMinutes($v['reminder_minutes'] ?? null),
self::statusOf($v['status'] ?? null),
0,
self::textOrNull((string)($v['rrule'] ?? ''), 255),
self::textOrNull((string)($v['note'] ?? '')),
$createdBy ?: null,
self::textOrNull((string)($v['extra'] ?? '')),
]);
return 'added';
}
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
/**
* 提醒值 → 存的字符串(v24 第 20 批 第 8 条:**可以多选**)。
*
* 用户原话:「日程的提醒,是可以多选的,就是说,一个日程,
* 我可以选择让它5分钟提醒,10分钟提醒等等。」
*
* ⚠️ 存的形态:**逗号分隔的分钟数升序**,如 `5,15,60`。
* 为什么不动表结构(不加新列、不改成 JSON):
* · 那一列是 `INT`,存 `"5,15,60"` 时 MySQL 会做隐式转换 —— 有风险,所以
* 把列改成 VARCHAR(见 Schema 的升级段),但**老值一个都不丢**(数字串照样读)。
* · 逗号串人眼可读、`WHERE reminder_minutes LIKE …` 也还能粗筛,
* 而 JSON 存进去就再也说不清"这列以前存过什么"。
*
* @param mixed $v 表单来的(数组 或 逗号串 或 单个数字)
* @return string|null 合法时返回规范化串,一个都没有时返回 null(=不提醒)
*/
private static function reminderMinutes($v): ?string
{
$mins = self::reminderList($v);
return $mins ? implode(',', $mins) : null;
}
/**
* v32 第 3 条:**到点的提醒**(浏览器通知用)。
*
* 用户原话:「还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
* 提前十分钟提醒,则需要在9:50时浏览器中发提醒通知。」
*
* @param string $from 窗口起点(含),'Y-m-d H:i:s'
* @param string $to 窗口终点(含)
* @return array<int,array{id:int,title:string,starts_at:string,at:string,cal:string,href:string}>
* `at` = 该触发提醒的时刻(starts_at - 提前分钟数)
*
* ⚠️ 为什么**只按 starts_at 查**、不做重复日程展开:
* 重复日程(RRULE)会在每周三都"提醒一次",但那需要把系列展开到具体某天,
* 而**同一次发生**在任何一次轮询里都必须算出**同一个提醒时刻**(否则会重复弹)。
* 窗口很短(前端每次只看"现在前后几分钟"),而"下次发生"恰好落在这几分钟里的
* 概率极低、影响也只是一个提醒来得晚一点。
* ⇒ 这一版覆盖"用户举的那个例子"(单次日程 10:00 提前 10 分钟 → 9:50 弹),
* 重复日程的提醒留待需要时再展开(不假装支持)。
*
* ⚠️ 多档提醒(`reminder_minutes` 是 `'5,15'`)会返回**多条**(每档一条):
* 用户既然设了两档,就该被提醒两次 —— 靠前端按 `id@at` 去重,不会重复弹同一条。
*/
public static function remindersBetween(int $familyId, int $personId,
string $from, string $to): array
{
// 我可见的日历本 = 我建的 + 共享给我的(与页面口径一致,别另发明一套)
$books = [];
foreach (self::mine($familyId, $personId) as $c) {
if (in_array((string)$c['kind'], ['movie', 'trip'], true)) {
continue; // 派生本没有 calendar_events 行,且它们本就没有提醒
}
$books[] = (int)$c['id'];
}
if (!$books) {
return [];
}
$in = implode(',', array_fill(0, count($books), '?'));
// ⚠️ 范围要放宽 `最大档位` 分钟:提醒时刻 = 开始 - N 分钟,
// 所以"开始时间"可能在窗口**之后**(10:00 的事件,9:50 时窗口是过去)。
// 取 REMINDERS 里最大档位当余量,一次查全。
$maxLead = max(array_keys(self::REMINDERS));
$st = Database::get()->prepare(
"SELECT e.id, e.title, e.starts_at, e.reminder_minutes, e.all_day,
c.name AS calname
FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.calendar_id IN ($in)
AND e.reminder_minutes IS NOT NULL AND e.reminder_minutes <> ''
AND e.status <> 'cancelled'
AND e.starts_at >= ? AND e.starts_at <= ?
ORDER BY e.starts_at"
);
$st->execute(array_merge($books,
[date('Y-m-d H:i:s', strtotime($from) - $maxLead * 60),
date('Y-m-d H:i:s', strtotime($to) + $maxLead * 60)]));
$out = [];
$fTs = strtotime($from);
$tTs = strtotime($to);
foreach ($st->fetchAll() as $e) {
if (!empty($e['all_day'])) {
continue; // 全天日程没有"开始时点",提前 N 分钟无从计算
}
$sTs = strtotime((string)$e['starts_at']);
if ($sTs === false) {
continue;
}
foreach (self::reminderList($e['reminder_minutes'] ?? null) as $lead) {
$atTs = $sTs - $lead * 60;
if ($atTs < $fTs || $atTs > $tTs) {
continue; // 这一档的提醒时刻不在本窗口
}
$out[] = [
'id' => (int)$e['id'],
'title' => (string)$e['title'],
'starts_at' => (string)$e['starts_at'],
'at' => date('Y-m-d H:i:s', $atTs),
'lead' => (int)$lead,
'cal' => (string)$e['calname'],
'href' => url('calendar/event', ['id' => (int)$e['id']]),
];
}
}
usort($out, static fn(array $a, array $b): int => strcmp($a['at'], $b['at']));
return $out;
}
/**
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
* ⚠️ 白名单:只认 `self::REMINDERS` 里那几个档位。手搓请求塞 999999
* 不能变成"提前 999999 分钟提醒"(那会导出一个客户端算不明白的 VALARM)。
*
* @param mixed $v 数组 / "5,15" / "5" / null
* @return int[]
*/
public static function reminderList($v): array
{
if ($v === null || $v === '' || $v === false) {
return [];
}
if (is_array($v)) {
$raw = $v;
} else {
$raw = explode(',', (string)$v);
}
$out = [];
foreach ($raw as $one) {
$one = trim((string)$one);
if ($one === '') {
continue;
}
$n = (int)$one;
if (isset(self::REMINDERS[$n]) && !in_array($n, $out, true)) {
$out[] = $n;
}
}
sort($out);
return $out;
}
/** 一个日程的提醒 → 人话('提前 5 分钟、提前 1 小时');没有则空串 */
public static function reminderWords($v): string
{
$words = [];
foreach (self::reminderList($v) as $n) {
$words[] = (string)self::REMINDERS[$n];
}
return implode('、', $words);
}
/** 老调用点还在用「单值」语义时(如 _event_fields 回显)取一个"是否含某一档" */
public static function hasReminder($v, int $mins): bool
{
return in_array($mins, self::reminderList($v), true);
}
private static function statusOf($v): string
{
$v = (string)$v;
return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed';
}
/* 🔴 v47(用户 2026-10-13):「日程的那个状态,什么确定、待定、取消,
我觉得没有什么意义,把它给删掉吧,不要了。」
⇒ `statusWord()` **删除**(浮层自 v44 起就已不显示状态,它最后也没有消费者了)。
⚠️ `statusOf()` **保留** —— 它在 Calendar 的三处写入路径(新建/更新/派生本)里
仍用于把外部传入的 status 规范化成合法枚举;列与导出契约都不动,
等"整体清理"那一轮再一起收(用户明确说了会做)。
/**
* 某个月(或某段日期)里**全部**日程:
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
* + 有截止时间的任务(tasks.due_at)。
*
* 统一成一种形状返回,模板不必分辨来源:
* ['cal'=>日历本id, 'color'=>本子颜色, 'title', 'date'=>'Y-m-d', 'time'=>'HH:MM'|'',
* 'all_day'=>bool, 'kind'=>'custom|movie|trip|task', 'ref_id'=>宿主id, 'href'=>点进去的地址]
*
* ⚠️ 观影/旅行只显示**本家庭**的记录(与观影模块同一个口径:能登录就能看)。
*/
public static function month(int $familyId, array $calendars, int $personId, string $from, string $to): array
{
$out = [];
$byKind = [];
foreach ($calendars as $c) {
$byKind[(string)$c['kind']][] = $c;
}
// ---- 自定义日程 ----
$ids = [];
foreach ($calendars as $c) {
if ((string)$c['kind'] === 'custom') {
$ids[] = (int)$c['id'];
}
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
// ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于
// 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。
// 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。
$st = Database::get()->prepare(
"SELECT * FROM calendar_events
WHERE calendar_id IN ($in)
AND DATE(starts_at) <= ?
ORDER BY starts_at"
);
$st->execute(array_merge($ids, [$to]));
$calById = [];
foreach ($calendars as $c) {
$calById[(int)$c['id']] = $c;
}
$rows = $st->fetchAll();
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
$skips = [];
$ovrs = [];
if ($rows) {
$eids = array_map('intval', array_column($rows, 'id'));
$in2 = implode(',', array_fill(0, count($eids), '?'));
$sk = Database::get()->prepare(
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)"
);
$sk->execute($eids);
foreach ($sk->fetchAll() as $r) {
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
}
/* 🔴 v52b:「被**改到别的时间**的某一次」(CalDAV 的 RECURRENCE-ID 覆盖行)
与 skips 一次批量取出来 —— 网页日历也得认它,否则手机上把周三那次
改到周五,网页上周三**照样**画着(还在原时间),等于两边不一致。 */
foreach (self::overridesFor($eids) as $eid => $list) {
foreach ($list as $o) {
/* ⚠️ 键必须是 **`Y-m-d`**(与 `Repeat::occurrences()` 返回的日期
同形态),**不能**直接用 ICS 形态的 `recurrence_id` ——
那样永远匹配不上,覆盖行会被静默忽略(网页上照样画在原时间)。 */
$od = self::ridDay((string)$o['recurrence_id']);
if ($od !== '') {
$ovrs[(int)$eid][$od] = $o;
}
}
}
}
foreach ($rows as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null;
/* 🔴 v49(用户 2026-10-14 第 2 条):条目详情浮层要按来源给按钮 ——
自定义日程给「详情/编辑/删除」,别的模块派生的只给「详情」。
「删除」还要求**我在这本日历上可写** ⇒ 这里就把结论算好带出去
(`fb_entry_json()` 读 `_can_write`)。模板里再逐条判一次会重复三处。 */
$canW = $c !== null && self::canWrite($c, $personId);
$sd = substr((string)$e['starts_at'], 0, 10);
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
$tm = substr((string)$e['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:周/日视图里色块的**高度要按日程时长**画
// (从开始时间到结束时间),所以结束时刻必须带出去。
$etm = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
$rr = (string)($e['rrule'] ?? '');
// 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
// 不重复的走同一条路(occurrences 会返回它自己那一天)。
$days = Repeat::occurrences(
(string)$e['starts_at'], $rr, $from, $to,
$skips[(int)$e['id']] ?? []
);
/* 🔴 v52b:覆盖行可能把某一次挪到**别的月份**去 ⇒ 只看"本窗口内的发生"
会漏掉它。反着也收一遍:原发生不在窗口里、但**新时间**落在窗口内的那几次。
⚠️ `$od` 已经是 `Y-m-d`(见上面建 `$ovrs` 时的 `ridDay`),别再转一次。 */
foreach (($ovrs[(int)$e['id']] ?? []) as $od => $o) {
$nd = substr((string)($o['starts_at'] ?? ''), 0, 10);
if ($nd >= $from && $nd <= $to && !in_array($od, $days, true)) {
$days[] = $od;
}
}
foreach ($days as $day) {
/* 这一次被改到别的时间了吗?有覆盖行就按覆盖行画。
⚠️ 三个"日期"要分清,混用会画出"整串都堆在系列第一天"的怪相:
· `$day` = 这一**次发生**在原系列里的日期(也是 `occ` 的值,
详情浮层与编辑链路靠它定位"改哪一次");
· `$base` = 这一次**画在哪一天**(有覆盖行 ⇒ 它改到的新日期);
· `$bsd/$bed` = 用来算**时长**的起止(有覆盖行用覆盖行自己的,
没给结束就别继承主事件的时长)。 */
$ov = $ovrs[(int)$e['id']][$day] ?? null;
$vd = $ov ?: $e;
$base = $ov ? substr((string)$ov['starts_at'], 0, 10) : $day;
$bsd = substr((string)$vd['starts_at'], 0, 10);
$bed = substr((string)($vd['ends_at'] ?: $vd['starts_at']), 0, 10);
$vtm = substr((string)$vd['starts_at'], 11, 5);
// 没给结束时刻就是"没有结束"(不继承主事件的时长,
// 否则"挪到下午茶"会被画成一个 9 小时的块)
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
$first = true;
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day')));
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
'calname' => (string)($c['name'] ?? ''),
'title' => (string)$vd['title'],
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($vd['all_day'])) ? $vtm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
'all_day' => !empty($vd['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
'location' => (string)($vd['location'] ?? ''),
'note' => (string)($vd['note'] ?? ''),
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
'occ' => $day,
'repeat' => $rr,
// v49:这一条我能不能改/删(浮层据此决定显不显「编辑/删除」)
'_can_write' => $canW,
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
];
$first = false;
}
}
}
}
// ---- 观影(events kind=movie)----
if (isset($byKind['movie'])) {
$c = $byKind['movie'][0];
$st = Database::get()->prepare(
"SELECT id, title, starts_at, ends_at FROM events
WHERE family_id = ? AND kind = 'movie' AND starts_at IS NOT NULL
AND DATE(starts_at) BETWEEN ? AND ?
ORDER BY starts_at"
);
$st->execute([$familyId, $from, $to]);
foreach ($st->fetchAll() as $e) {
$day = substr((string)$e['starts_at'], 0, 10);
$t = substr((string)$e['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:周/日视图色块高度按真实时长画 ——
// 一部电影两小时,在时间网格里就该占两小时(ends_at 库里本来就有)。
$et = $e['ends_at'] ? substr((string)$e['ends_at'], 11, 5) : '';
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => '🎬 ' . (string)$e['title'],
'date' => $day,
'time' => $t === '00:00' ? '' : $t,
'end_time'=> $t === '00:00' ? '' : ($et === '00:00' ? '' : $et),
'all_day' => $t === '00:00',
'kind' => 'movie',
'ref_id' => (int)$e['id'],
'href' => url('movies/show', ['id' => (int)$e['id']]),
];
}
}
// ---- 旅程(trip_nodes,不是 events)----
// ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。
// 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满,
// 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
if (isset($byKind['trip'])) {
$c = $byKind['trip'][0];
// 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。
// COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
// v24 第 13 批(用户 2026-10-05):旅程重构后,**按 stage 过滤**。
// · plan → 上日程(用**计划**时间)
// · actual → 上日程(时间已经是实际时间,因为"修正"会把实际值写回行程)
// · skipped → **不上日程**(用户:"就权当没有这个日程")
// ⚠️ 不能再按 status<>'cancelled' 过滤 —— 那是旧双轨的口径;
// 现在"未成行"是 stage='skipped',而 status 可能仍是 planned。
$st = Database::get()->prepare(
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
n.node_type, n.status AS node_status, n.stage AS node_stage,
e.id AS event_id, e.title AS trip_title
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
AND n.stage IN ('plan','actual')
AND COALESCE(d.is_public, 1) = 1
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
ORDER BY n.starts_at"
);
$st->execute([$familyId, $to, $from]);
foreach ($st->fetchAll() as $n) {
$start = substr((string)$n['starts_at'], 0, 10);
$end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10);
// 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了")
$tm = substr((string)$n['starts_at'], 11, 5);
// 用户 2026-10-03 第 5 条:色块高度按时长画 —— 节点的结束时刻也要带出去
$etm = $n['ends_at'] ? substr((string)$n['ends_at'], 11, 5) : '';
$first = true;
foreach (self::eachDay($start, $end, $from, $to) as $day) {
$isLast = ($day === $end);
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。
'title' => Trip::typeIcon((string)$n['node_type'])
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ')
. (string)$n['node_title'],
'date' => $day,
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
'end_time'=> (!$first || $isLast) && $tm !== '00:00' && $etm !== '' ? $etm : '',
'all_day' => $first && $tm === '00:00',
'kind' => 'trip',
'ref_id' => (int)$n['node_id'],
'href' => url('trips/show', ['id' => (int)$n['event_id']])
. '#node-' . (int)$n['node_id'],
];
$first = false;
}
}
}
// ---- 农历(v24 第 10 批 #4)----
// 用户原话:「日程中,增加两个不可删除的日历,一个是农历…」
// 每一天都出一条(标题 = 农历月日),农历节日另标一个名字。
// 它是**派生只读**的:不落 calendar_events,删不掉,也不能往里加日程。
if (isset($byKind['lunar'])) {
$c = $byKind['lunar'][0];
foreach (Holiday::lunarRange($from, $to) as $l) {
$fest = (string)($l['fest'] ?? '');
$title = (string)$l['title'] . ($fest !== '' ? ' · ' . $fest : '');
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => $title,
'date' => (string)$l['date'],
'time' => '',
'all_day' => true,
'kind' => 'lunar',
'ref_id' => 0,
// 农历本没有可点进去的详情;给一个月视图锚点即可(点它=看那天)
'href' => url('calendar', ['view' => 'day', 'd' => (string)$l['date']]),
];
}
}
// ---- 节假日:放假 + 调休(v24 第 10 批 #4)----
// 用户原话:「一个是法定假日的休息和调休信息,法定假日这个,需要有自动更新功能…
// 不是让你更新,而是让系统,代码自动更新。」
// 自动更新在 App\Holiday 里:内置规则保证"永远有",官方数据一到就覆盖成官方口径。
if (isset($byKind['holiday'])) {
$c = $byKind['holiday'][0];
// [from,to] 可能跨年(12 月末的月视图会看到次年 1 月初),两年都要取
$years = [(int)substr($from, 0, 4), (int)substr($to, 0, 4)];
foreach (array_unique($years) as $y) {
foreach (Holiday::range($y, $from, $to) as $h) {
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => (string)$h['title'],
'date' => (string)$h['date'],
'time' => '',
'all_day' => true,
'kind' => 'holiday',
'ref_id' => 0,
'href' => url('calendar', ['view' => 'day', 'd' => (string)$h['date']]),
];
}
}
}
// ---- 传统节日(v24 第 12 批 #4)----
// 用户原话:「增加系统日历本,一个是传统节日,用来存放中国的传统节日,
// 例如中秋、春节等。」
// 全部由农历推导(Holiday::traditionalRange),断网也永远有。
if (isset($byKind['traditional'])) {
$c = $byKind['traditional'][0];
foreach (Holiday::traditionalRange($from, $to) as $f) {
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => (string)$f['title'],
'date' => (string)$f['date'],
'time' => '',
'all_day' => true,
'kind' => 'traditional',
'ref_id' => 0,
'href' => url('calendar', ['view' => 'day', 'd' => (string)$f['date']]),
];
}
}
// ---- 二十四节气(v24 第 12 批 #4)----
// 用户原话:「再增加一个系统日历本,用来存放24节气。」
if (isset($byKind['solarterm'])) {
$c = $byKind['solarterm'][0];
foreach (Holiday::solarTermRange($from, $to) as $s) {
$out[] = [
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => (string)$s['title'],
'date' => (string)$s['date'],
'time' => '',
'all_day' => true,
'kind' => 'solarterm',
'ref_id' => 0,
'href' => url('calendar', ['view' => 'day', 'd' => (string)$s['date']]),
];
}
}
// ---- 有截止时间的任务 ----
$st = Database::get()->prepare(
"SELECT t.id, t.title, t.due_at, t.status
FROM tasks t
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
WHERE t.family_id = ? AND t.due_at IS NOT NULL AND t.status <> 'done'
AND DATE(t.due_at) BETWEEN ? AND ?
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
ORDER BY t.due_at"
);
$st->execute([$personId, $familyId, $from, $to, $personId, $personId]);
foreach ($st->fetchAll() as $t) {
$day = substr((string)$t['due_at'], 0, 10);
$tm = substr((string)$t['due_at'], 11, 5);
$out[] = [
'cal' => 0,
'color' => '#A8574F',
'calname' => '任务',
'title' => '☑ ' . (string)$t['title'],
'date' => $day,
'time' => $tm === '00:00' ? '' : $tm,
// 任务只有"截止时刻"一个点,没有时长 → 不给 end_time,
// 让时间网格按"最短可见块"画(见 build_time_grid)。
'end_time'=> '',
'all_day' => $tm === '00:00',
'kind' => 'task',
'ref_id' => (int)$t['id'],
'href' => url('tasks/show', ['id' => (int)$t['id']]),
];
}
// ---- 纪念日(v24 第 13 条)----
// 用户明确要求:纪念日一律作为**全天日程**出现在日历里。
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
$out[] = [
'cal' => 0,
'color' => '#B4633C',
'calname' => '纪念日',
'title' => '🎂 ' . (string)$a['title'],
'date' => (string)$a['date'],
'time' => '',
'end_time'=> '',
'all_day' => true, // 全天(用户点名要求)
'kind' => 'anniv',
'ref_id' => (int)($a['id'] ?? 0),
'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'),
];
}
usort($out, static function ($a, $b) {
return strcmp($a['date'], $b['date'])
?: strcmp((string)$a['time'], (string)$b['time'])
?: strcmp($a['title'], $b['title']);
});
return $out;
}
/**
* 🌟 时间冲突分列(v30 第 4 条)—— 「两个日程撞在一起不再堆叠,要错开画」。
*
* 用户原话:「日程中,如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
* 显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
* 并指定「这个显示效果,在 SyncCalendar 这个代码中有算法,你去读一下,借鉴一下」。
*
* ⇒ 算法逐行对照 SyncCalendar/entry/src/main/ets/common/TimelineUtil.ets 的
* build() + groupsOf() 搬过来(那边跑在 ArkTS 上,这里是 PHP,但**判据一字未改**):
* ① 先划分「冲突组」:按开始时间升序,一条与组内**最晚结束**还存在重叠
* (`start < endMax`)就并进这一组 —— 这是"传递性连通",注意它会把
* 「A 7:30-9:30 / B 8:20-9:00 / C 9:15-9:55」并成一组;
* ② 组内**贪心分列**(区间图着色):每条放进"结束时间 <= 它开始时间"的
* **编号最小**的那一列;开不出新列时才加一列。
* ⇒ 列数 = 该组的**最大同时重叠数**,不是组内条数 ——
* 上面那个例子里 B 与 C 并不重叠,可共用一列,所以只需 2 列而不是 3 列。
* 这正是 SyncCalendar 注释里特意点出的"旧算法按传递性重叠分 3 列"的错处。
* ③ 宽度 = 1/列数,左偏移 = 列号/列数 —— 于是 2 条冲突各占一半、3 条各占三分之一。
*
* ⚠️ 每条日程的"结束分钟"若缺失或 <= 开始(跨天日程的第二天、或用户填反了),
* 按 30 分钟的最短可见块参与分列 —— 与 build_time_grid 的口径保持一致,
* 否则"没有结束时间"的那些会被算成零长、永远不参与冲突。
* ⚠️ **只在有时间的事件之间判冲突**:全天日程不进时间网格(它们挂顶上那一行)。
*
* @param array $events 某一天的时间型事件(元素含 time / end_time,单位 'HH:MM')
* @return array 与入参同序的 [left, width](都是 0~1 的比例;无冲突时是 [0,1])
*/
public static function conflictLanes(array $events): array
{
$n = count($events);
if ($n <= 1) {
return array_fill(0, $n, [0.0, 1.0]);
}
// ---- 前缀扫描动作:把每条事件换成一个"动作"(start 记 +1、end 记 -1)----
// 这样"任意一点上有几条重叠"就是同一个坐标上的动作个数 —— 这正是
// SyncCalendar 的贪心分列(laneEnd <= start ⇒ 复用该列)的等价表述,
// 但**完全不需要保留 laneEnd 数组**,也就绕开了浮点比较。
// ⚠️ 不能简单地按"起点同时刻"分组:结束点也要参与,且同时刻的 end 必须先于 start
// 处理(否则"9:00 结束的"与"9:00 开始的"会被算成重叠)。
// ---- ① 归一成 [下标, 开始, 结束](结束缺失/倒挂 → 30 分钟最短块,同上口径)----
$rows = [];
foreach ($events as $i => $e) {
$s = self::minutesOf((string)($e['time'] ?? ''));
if ($s === null) {
continue;
}
$en = self::minutesOf((string)($e['end_time'] ?? ''));
if ($en === null || $en <= $s) {
$en = $s + 30;
}
$rows[] = ['i' => $i, 's' => $s, 'e' => min($en, 1440)];
}
if (!$rows) {
return array_fill(0, $n, [0.0, 1.0]);
}
// 按开始时间升序(同开始则长的在前 —— 与 SyncCalendar 的稳定排序一致)
usort($rows, static function (array $a, array $b): int {
return $a['s'] <=> $b['s'] ?: $b['e'] <=> $a['e'];
});
// ---- ② 划分冲突组:与组内**最晚结束**仍重叠(start < endMax)就并进本组 ----
$groups = [];
$cur = [];
$endMax = -1;
foreach ($rows as $r) {
if ($cur && $r['s'] >= $endMax) {
$groups[] = $cur;
$cur = [];
$endMax = -1;
}
$cur[] = $r;
$endMax = max($endMax, $r['e']);
}
if ($cur) {
$groups[] = $cur;
}
// ---- ③ 组内贪心分列:每列记"最后结束分钟",放进编号最小的可用列 ----
// ⚠️ 列数是**每组各自**的(组与组之间时间不重叠,可互相复用 0 号列)——
// 所以一个"孤立日程"仍然是整宽,不会被别处的三方冲突连累成 1/3 宽。
$out = [];
for ($i = 0; $i < $n; $i++) {
$out[$i] = [0.0, 1.0];
}
foreach ($groups as $g) {
$laneEnd = []; // 每列最后结束分钟
$laneOf = []; // 本组第 k 条 → 列号
foreach ($g as $k => $r) {
$lane = -1;
foreach ($laneEnd as $li => $le) {
if ($le <= $r['s']) { $lane = $li; break; }
}
if ($lane < 0) {
$lane = count($laneEnd);
$laneEnd[] = $r['e'];
} else {
$laneEnd[$lane] = $r['e'];
}
$laneOf[$k] = $lane;
}
$cols = max(1, count($laneEnd));
foreach ($g as $k => $r) {
$out[$r['i']] = [$laneOf[$k] / $cols, 1 / $cols];
}
}
return $out;
}
/** 'HH:MM' → 分钟数;空/不合法返回 null(第 4 条的分列用) */
private static function minutesOf(string $t): ?int
{
if (preg_match('/^(\d{1,2}):(\d{2})/', trim($t), $m)) {
return ((int)$m[1]) * 60 + (int)$m[2];
}
return null;
}
/** 把 [start, end] 夹到 [from, to] 里,逐天列出(含端点) */
private static function eachDay(string $start, string $end, string $from, string $to): array
{
$s = max($start, $from);
$e = min($end, $to);
if ($s > $e) {
return [];
}
$out = [];
$cur = $s;
for ($i = 0; $i < 400 && $cur <= $e; $i++) {
$out[] = $cur;
$cur = date('Y-m-d', strtotime($cur . ' +1 day'));
}
return $out;
}
private static function nextPosition(int $ownerId): int
{
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM calendars WHERE owner_id = ?');
$st->execute([$ownerId]);
return (int)$st->fetchColumn();
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
}