Files
FamilyBook/app/Calendar.php
T
laoyang c3d00ad66a v24 第 20 批:拖动即时上传 / 观影图片带出 / 行程保存修复 / 日程红线与删除范围与多提醒 / 悬停统一(用户 9 条)
用户 9 条原话要点:
  ①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
  ②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
     这个卡片,不要了。我的资料这个卡片,不要了。」
  ③「旅程模块,编辑行程,点击保存没有反应。」
  ④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
     就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
  ⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
     其他情况下默认不出现。出发地和到达地,应该在同一行。」
  ⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
     用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
  ⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
     还是删除这一次及以后的。」
  ⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
     10 分钟提醒等等。」
  ⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
     保持统一,但是不要单纯的换一个不协调的背景色。」

🔴 三个真根因(都是"看着不像 bug"的地方):
  1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
     套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
     `</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
     `button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
     且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
     → 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
  2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
     作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
     **本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
  3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
     不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
     `display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
     **只有真机量尺寸才发现** —— 由新写的真机探针抓到。

本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
  `templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
  放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
  `media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
  ⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
  (还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
  ⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
  ⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
  新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
  新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
  与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
  前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
  只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
  老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
  (INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
  有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
  + 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
  清成"交给统一段"。`person_picker.js` 同时改为按需加载。

验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
  **真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
  ④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
  过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
2026-10-06 03:14:18 +08:00

1046 lines
48 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'],
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
// 系统日历本,用来存放24节气。」)—— 两个都由 Holiday 派生、都不可删。
'traditional' => ['传统节日', '#A6305C'],
'solarterm' => ['二十四节气', '#307AA6'],
];
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
public const READONLY_KINDS = ['movie', 'trip', '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;
}
}