用户 2026-10-05 的 5 条。第 11 批交付后用户追问"确定这些需求都做完了?"—— 没有, 这 5 条是进一步要求。实现过程中查出 4 个真 bug。 #1 详情页删除按钮紧贴「开通账号」 上一版放在页面最下方独立区块(danger-zone),用户要「就放在开通账号按钮下面, 紧挨着,长度大小和开通账号一样,颜色改成红色」。→ 搬进「登录账号」卡片内 (.del-inline + 细分隔线 + .btn-block-lg)。 线上几何实测:同一张卡片、左边缘对齐 255==255、宽度一致 990==990、红色 rgb(192,83,63)。 #2 日期控件三修(两个真 bug + 一次闪烁) · 灰底:Semi **外层** .semi-datepicker-input 自带 rgba(46,50,56,.05)、高 32px; 内层 wrapper 白底 40px → 灰的露在外面。实测「灰底元素」= 1 个 → 修后 0 个。 · 不垂直居中:外层 32px < 内层 40px,实测 gapTop=1 / **gapBottom=17**(文字贴顶) → 修后 gapTop=1 / gapBottom=1。 · 闪烁:实测首 **200ms** 原生输入框可见(要等 3.4MB React 解析)。 用 no-js/js 二态:<html class="no-js"> + head 内联脚本立刻换 js + CSS `html.js .dt-input:not(.is-semi) .dt-native { visibility:hidden }` (配同高骨架 ::after 防布局跳动)。实测「出现过原生可见」= False。 #3 日历跳转保留视图 + 下拉日历 原来写死 view=day(在月视图点跳转会被踢进日视图)。→ 表单带当前 $view(月还带 ym); 选择器从"居中弹窗"改成**按钮下方下拉** .cal-jump-drop。 实测:月视图跳 8/15 仍在月视图且是 8 月;周视图跳 8/15 → 落 8/10 那一周; 日视图跳 8/15 → 显示 8/15。 #4 传统节日 + 二十四节气两个系统本(Schema v18) Holiday::SOLAR_TERMS(24 项 C 常数近似公式 + 例外表)+ traditionalRange (农历推导;除夕用"次日是否正月初一"反推,写死腊月廿九会有一半年份错)。 实测 2026:节气 24 条全对(清明 4/5、冬至 12/22);春节 2/17、除夕 2/16、中秋 9/25。 ⚠️ ENUM 必须显式 ALTER(对已存在的表不生效)——同一坑第二次踩,VERSION 17→18。 #5 财务(一个真 bug + 一个假起点 + 一个真 bug) · chart_line 读 `cents` 而 timeline() 返回 `net` → 键名不匹配,**总资产图每点读成 0** (正是用户说的"图上是 0")。修:兼容 cents ?? net ?? asset。 实测 timeline net=800000(8000 元),线上 polyline 有真实起伏。 · 去掉 timeline 人为插的"前一天=0"假起点(用户:"总资产不应该是 0, 应该是所有账目创建时的金额的总数")。 · ppSetSelected 只用 document.querySelector 按名字找,而财务页有**两个** person_id 组件(「记一笔」+ 修改弹框)→ 永远灌到第一个。加 root 参数、调用方传 panel。 验证:e2e 786/0、交互 92/0、升级 129/0(+3 条 v18 断言)、batch_e 91/0、batch10 46/0、 batch9 38/0、picker_unify 28/0、picker_lazy 11/0、crop 17/0、formbloat 9/0、semi 27/0、 mobile 688/0、drag 5/0、geom 5/5;第 12 批专项:财务 7/0、日历 19/0。 部署静态断言新增 5 组(含反向验证:注入回退 → FAIL=1)。 线上自检 19/19;线上真实账号验收 **30/0**(含真实浏览器几何量删除按钮位置)。
973 lines
44 KiB
PHP
973 lines
44 KiB
PHP
<?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(不提醒) */
|
||
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']]),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 传统节日(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;
|
||
}
|
||
}
|