Files
FamilyBook/app/Calendar.php
T
laoyang f0437e6c93 v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)
用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
2026-10-06 12:08:40 +08:00

1079 lines
50 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']);
}
}
// ---------------- 共享 ----------------
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(不提醒) */
/**
* 提醒值 → 存的字符串(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;
}
/**
* 任意形态的提醒值 → **合法分钟数数组**(升序、去重)。
*
* ⚠️ 白名单:只认 `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';
}
/**
* 某个月(或某段日期)里**全部**日程:
* 自定义日程(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 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
// 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;
}
/** 把 [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;
}
}