Files
FamilyBook/app/Calendar.php
T
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00

927 lines
42 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 = [
'movie' => ['观影', '#7A4E8C'],
'trip' => ['旅行', '#3F7D5A'],
'lunar' => ['农历', '#8C6D3F'],
'holiday' => ['节假日', '#B0342C'],
];
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
public const READONLY_KINDS = ['movie', 'trip', 'lunar', 'holiday'];
/** 这本是不是"派生只读"的(农历/节假日/观影/旅行都算) */
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']);
}
}
// ---------------- 共享 ----------------
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
{
Database::get()->prepare(
'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?,
reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1
WHERE id=?'
)->execute([
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'] ?? ''),
$id,
]);
}
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());
}
/**
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
*
* 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义,
* 「仅此一次」的改动不能碰它。所以:
* ① 在原系列里把这一天标记为跳过(不再出现原样那次);
* ② 新建一条只发生这一次的日程,内容 = 用户改后的。
* 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。
*
* @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 = [];
$seen = [];
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
if (isset($seen[$key])) {
continue; // 跨天日程会逐天出一条,导出时一条就够
}
$seen[$key] = true;
$day = (string)$e['date'];
$out[] = [
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
'title' => (string)$e['title'],
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
'ends_at' => null,
'all_day' => empty($e['time']) ? 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']]);
return $st->fetchAll();
}
/**
* 导入一条 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)
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,
]);
return 'added';
}
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
private static function reminderMinutes($v): ?int
{
if ($v === null || $v === '' || $v === false) {
return null;
}
$n = (int)$v;
return isset(self::REMINDERS[$n]) ? $n : null;
}
private static function statusOf($v): string
{
$v = (string)$v;
return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed';
}
/**
* 某个月(或某段日期)里**全部**日程:
* 自定义日程(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 = [];
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;
}
}
foreach ($rows as $e) {
$c = $calById[(int)$e['calendar_id']] ?? null;
$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']] ?? []
);
foreach ($days as $day) {
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
$first = true;
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
$isLast = ($d2 === date('Y-m-d', strtotime($day . ' +' . $span . ' day')));
$out[] = [
'cal' => (int)$e['calendar_id'],
'color' => (string)($c['color'] ?? '#4A7C82'),
'calname' => (string)($c['name'] ?? ''),
'title' => (string)$e['title'],
'date' => $d2,
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
'time' => ($first && empty($e['all_day'])) ? $tm : '',
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
'end_time'=> ($isLast && $etm !== '' && empty($e['all_day'])) ? $etm : '',
'all_day' => !empty($e['all_day']),
'kind' => 'custom',
'ref_id' => (int)$e['id'],
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
'occ' => $day,
'repeat' => $rr,
'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 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
$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,
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.status <> 'cancelled'
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_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']]),
];
}
}
}
// ---- 有截止时间的任务 ----
$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;
}
/** 把 [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;
}
}