Files
FamilyBook/app/Anniversary.php
T
laoyang 26026c7250 v53:纪念日记年份算「第几个」/ 首页待办排在进行中下面 / 日程编辑浮层跨满整行 / 新增成员打标签 / 新增页一次填完称呼与备注
用户 2026-10-16 五条。

① 纪念日要记录年份,才能知道「第几个」
   用户原话:「纪念日需要记录年份,这样才能知道**是第几个纪念日**。」
   **真根因有两层**(两处一起才构成"没有年份"):
     · 表里没有:anniversaries 只有 month + day(当年当成"一年一度,年份无意义");
     · 表单有、但被丢掉:日期控件本来就带年份(<input type="date"> 是 YYYY-MM-DD),
       而拆分 JS **只取 mm/dd**(`mo.value = m[2]; dy.value = m[3];`)⇒ 想存也没存上。
   修法:anniversaries.year(SMALLINT UNSIGNED NULL)+ 升级路径 addColumn;
     表单补 hidden year、拆分 JS 一并写;Anniversary::save 收 year(**区间守卫 1900..2100**);
     occurrences() 输出 year 与 nth;列表页显示「2018 年起 · 第 9 个」。
   ⚠️ 年份**可空、且不给默认值**:老数据就是没有年份,猜一个(按创建年之类)
      会让页面算出一个**错的**"第 N 个"。NULL = 只按月日过、不显示序号 —— 诚实。
   ⚠️ 顺带修一个自相矛盾:repeat_type='once' 原来落在公历分支 ⇒ **年年都冒出来**;
      现在有年份了 ⇒ 只在那一年出现(没年份的保持老行为,不凭空丢)。
   🔴 **序号算法抽成单点来源 `Anniversary::nthFor()`** —— `occurrences()`(临近区)与
      days/index.php(我添加的那张表)**都调它**;两处各写一遍是"改一处忘一处"的温床。
      用**今天所在的年**("今年是第几个"),不是"下一次发生那年"。
   🔴🔴 **踩坑(最值钱)**:`addColumn`(ALTER TABLE)**必须排在那张表 CREATE TABLE 之后**。
      第一版放进 movie_details 那段(anniversaries 的建表更靠后)⇒ 全新安装
      `1146 Table 'fb_test.anniversaries' doesn't exist` ⇒ 整段 ensure() 抛异常 ⇒
      **install 直接"安装失败"**(POST 回 200、不自删、schema_version=None、库一片空白)。
      新建库走 CREATE TABLE、老库走 addColumn —— **两条路都要在**。

② 首页近期任务:把待办也放进来(排在进行中下面)
   用户原话:「首页的近期任务,**把待办也放进来吧,放在进行中的下面**。」
   原来只收「进行中 + **已逾期**的待办」(当年怕待办一多首页变长清单)。
   ⇒ 现在**全部**收进来、按**状态分组**(doing 在前、todo 在后);
     两组内部仍按截止时间升序(byStatus 的 ORDER BY 已排好)。
   ⚠️ 配套改统计:`count($tasks) 件在做` 会把待办也算成"在做" ⇒ 改成**按状态拆开数**
      (进行中→「件在做」、待办→「件待办」,两数相加=列表行数,仍同源)。
      「件待办」原读 stat7['todos'](**与列表不同源**,v24 踩过"数字和列表对不上")。
   ⚠️ 空态:「没有进行中的任务,也没有待办。」
   ⚠️ .hd-pane-body 本来就是 overflow-y:auto + flex:1 1 auto/min-height:0,行数变多**内部滚动**、
      卡片高度不变(v32"两块外框恒等高"那套兜住了)。

③ 日程编辑浮层「只有左侧有内容,右侧空白,好像分栏了」
   **真根因**(assets/css/app.css):日程编辑页 event.php 里,`.mv-grid`(观影式**纵向**列表)
   被嵌在**两列**的 `.form-grid` 里 ⇒ 它只占**第一列**。整页形态下看不出来(页面宽),
   但**浮层里面板只有 620px** ⇒ 两列各 286px ⇒ 所有字段挤在左边 286px、右边整列空着。
   实测:panel=620 / form-grid=588 / cols="286px 286px" / mv-grid w=286
        →修后 mv-grid w=588(95% of panel)、标题输入框 w=516、距右缘 ~2px。
   修法:`.form-grid > .mv-grid { grid-column: 1 / -1; }` —— **一行 CSS**。
   ⚠️ 不去改 event.php 里"整页保留两列、浮层用 mv-grid"那个结构判断(那是有意保留的);
      `.mv-grid` 本身就是"一整块、占满宽度"的语义,天生不该被塞进半列。
   ⚠️ 排查手法:这种"看起来像分栏"的问题**量几何比读 CSS 快** ——
      getBoundingClientRect 打出 panel/grid/cols/内层宽,一眼看出"嵌了几层 grid"。

④ 添加成员时是**打标签**,不是选分组
   用户原话:「名册中添加成员时,**不是选择分组,而是打标签**。现在已经没有分组的概念了,
   **只有标签的概念**。」
   ⇒ 新增分支的「所属分组」下拉(group_id)换成**标签输入**(复用 v51 的 #ptBlock 那套:
      候选勾选 + 自由输入,**文本框是唯一提交源**)。
   ⚠️ 只在**新增**时出现在那个位置(编辑时上面那个 #ptBlock 已在管标签,出现两块会让人以为填两遍)。
   ⚠️ **分组表结构 person_roster.group_id 保留不动**(名册"拖动归档"还在用、历史数据也在),
      只是**新增入口不再要求人选**。
   ⚠️ e2e 里 v14「新增表单**有**分组下拉」与新需求正相反 ⇒ 改成「没有 group_id + 有 name=tags」。

⑤ 添加成员页一次填完「我对他的称呼 + 我的备注」
   用户原话:「我在添加一个成员的时候,是不是可以直接在添加成员的页面里填写**我对他的称呼**
   以及**我对他的备注**,**一次就解决**?要不然还得先添加、再返回他的名册去填,有点麻烦。」
   ⇒ 新增页也出现这两栏:
     · **我对他的备注**(person_notes)—— 条件从 `$isEdit && !$isSelf` 改成 `!$isSelf`;
     · **我对他的称呼**(nicknames)—— **新加的输入框** `name="my_nick"`。
   服务端 people/create 落两表(挂**我**名下:viewer=我, target=他)。
   ⚠️ 判"表单里有没有这一栏"用 **array_key_exists**(不是判值是否为空)——
      与编辑路径同一口径:**空串是明确的"清空"语义**(Nickname::set('') 会删行)。
   ⚠️ 四处**重渲染路径**(撞名确认/校验失败/手机号重复/people/new 初始)都要带上
      myTags/tagChoices/myNick,否则一撞名或一报错,刚打的标签与刚写的称呼备注就全没了。
   ⚠️ 编辑页也补 myNick 回显(否则"新增时填了、编辑页看不见"会以为没存上)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v53_ui.py(**26 条**真机断言)
 · _b53_falsify.py(**10 注入 / 10 全抓**,基线 26/0)
 · e2e **1517/0**;familybook_deploy.sh 加 v53 静态断言段(14 正向 + 1 反向)
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第53批形态")
 · 线上 8 文件 md5 逐字节一致;`anniversaries.year` 已由升级路径补上(nullable smallint);
   线上实测 nthFor 4 用例全对(2018→第9个 / null→NULL / 2019→2 / 未来年→NULL);
   诊断页已删并复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md / 工作日志):
 · **`addColumn` 必须在同表的 CREATE TABLE 之后**(否则全新安装 `1146` ⇒ ensure() 抛异常);
 · **探针里 `dispatchEvent(new Event('submit'))` 不会真的提交表单**
   (不导航、不发请求)⇒ 判"存没存上"必须真点提交按钮或 `requestSubmit()`;
 · **反向验证的 `restore()` 不能只是"再跑一遍 build_work()"**(指纹闸门会把它变空操作
   ⇒ 第二轮基线是脏的,26/0 掉到 14/12);恢复必须**无条件** copy2;
 · **注入锚点在源码里出现两次 ⇒ 打到别处 ⇒ 那条断言恒不红**
   (v53 给新增页也加了 `id="ptBlock"`,打偏了 v51 那条"编辑页标签块"的注入);
 · **别把两个共用 fb_test 库的套件并行跑**(互相删播种行 ⇒ 看着像"整套功能坏了");
 · **部署静态断言的锚点引号/反斜杠必须与源码一字不差**:`\[` 在 bash 双引号里是
   **字面反斜杠**、单双引号不能混、漏个 `(int)` 也搜不到 —— 一轮里连红 4 条全是这个。
2026-10-16 16:51:04 +08:00

563 lines
24 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
/**
* 纪念日(v24 用户第 13 条)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。
* 所有的纪念日,都显示在日历的日程中,作为一个全天日程出现。
* 纪念日,需要按照阴历和阳历进行重复。」
*
* 三条设计决定:
*
* ① **生日不落这张表**,而是像观影/旅程那样**实时派生**。
* 理由:生日已经存在 persons(birthday / birthday_lunar),再复制一份到
* anniversaries 就会出现"改了生日,纪念日那边还是旧的"。派生出来,
* 改生日当天纪念日跟着变 —— 与"日历里观影/旅程是实时派生"同一套口径。
*
* ② 用户自建的纪念日才落库(anniversaries 表)。`repeat_type` 决定怎么"年年重现":
* solar = 每年公历的同月同日;lunar = 每年农历的同月同日(所以库里存的是
* **农历的月/日**,不是公历换算结果 —— 换算结果只对某一年有效)。
*
* ③ 进日历、而且是**全天**日程(用户明确要求)。所以渲染时 all_day=1、
* 时间部分留空。
*/
declare(strict_types=1);
namespace App;
final class Anniversary
{
/** 重复方式 */
public const REPEAT = [
'solar' => '每年(阳历)',
'lunar' => '每年(农历)',
'once' => '只此一次',
];
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ----------------
public static function list(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 新建 / 修改一条纪念日。
*
* @param array $d title / repeat_type / month / day / kind / note / leap
* + **person_ids[]**(v24 第 25 批:可以关联多个人)
* @return int 记录 id
*/
public static function save(int $familyId, array $d, int $id = 0): int
{
$db = Database::get();
$title = trim((string)($d['title'] ?? ''));
if ($title === '') {
$title = '纪念日';
}
$repeat = (string)($d['repeat_type'] ?? 'solar');
if (!isset(self::REPEAT[$repeat])) {
$repeat = 'solar';
}
$month = max(1, min(12, (int)($d['month'] ?? 1)));
$day = max(1, min(31, (int)($d['day'] ?? 1)));
/* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,这样才能知道
**是第几个纪念日**。」
⇒ 年份是**可选**的:用户没填(或填了个不合理的)就存 NULL,
页面只按月日过、不显示"第 N 个" —— 比猜一个年份诚实。
⚠️ 合理区间 1900..2100:防手滑输入 0 / 999999 把"第 N 个"算成天文数字。 */
$year = (int)($d['year'] ?? 0);
$year = ($year >= 1900 && $year <= 2100) ? $year : null;
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
$note = trim((string)($d['note'] ?? ''));
$leap = !empty($d['leap']) ? 1 : 0;
// ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)----
//
// ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。
// 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。
$pids = [];
if (array_key_exists('person_ids', $d)) {
foreach ((array)$d['person_ids'] as $x) {
$x = (int)$x;
if ($x > 0) {
$pids[] = $x;
}
}
} elseif (array_key_exists('person_id', $d)) {
$x = (int)$d['person_id'];
if ($x > 0) {
$pids[] = $x;
}
}
$pids = array_values(array_unique($pids));
// 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上)
if ($pids) {
$in = implode(',', array_fill(0, count($pids), '?'));
$st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)");
$st->execute(array_merge([$familyId], $pids));
$ok = array_map('intval', array_column($st->fetchAll(), 'id'));
$pids = array_values(array_intersect($pids, $ok));
}
// ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL):
// 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。
$pid = $pids[0] ?? null;
if ($id > 0) {
$db->prepare(
'UPDATE anniversaries SET title=?, repeat_type=?, year=?, month=?, day=?, is_leap=?,
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
)->execute([$title, $repeat, $year, $month, $day, $leap, $kind !== '' ? $kind : null,
$note !== '' ? $note : null, $pid, $id, $familyId]);
self::savePeople($id, $pids);
return $id;
}
$db->prepare(
'INSERT INTO anniversaries (family_id, title, repeat_type, year, month, day, is_leap,
kind, note, person_id)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $title, $repeat, $year, $month, $day, $leap,
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
$newId = (int)$db->lastInsertId();
self::savePeople($newId, $pids);
return $newId;
}
/**
* 整组替换某个纪念日关联的人(v24 第 25 批)。
*
* 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**,
* 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。
*/
public static function savePeople(int $anniversaryId, array $personIds): int
{
$db = Database::get();
$db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]);
if (!$personIds) {
return 0;
}
$ins = $db->prepare(
'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)'
);
$n = 0;
$i = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$i += 10;
$ins->execute([$anniversaryId, $pid, $i]);
$n++;
}
return $n;
}
/**
* 某个纪念日关联的人(**含兜底**)。
*
* ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有):
* 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半"
* 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。
*
* @return int[] person id(按 sort_order)
*/
public static function peopleOf(array $row): array
{
static $cache = [];
$aid = (int)($row['id'] ?? 0);
if ($aid <= 0) {
return [];
}
if (array_key_exists($aid, $cache)) {
return $cache[$aid];
}
$st = Database::get()->prepare(
'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id'
);
$st->execute([$aid]);
$out = array_map('intval', array_column($st->fetchAll(), 'person_id'));
if (!$out) {
$one = (int)($row['person_id'] ?? 0);
if ($one > 0) {
$out = [$one];
}
}
$cache[$aid] = $out;
return $out;
}
/**
* 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。
*/
public static function peopleMap(array $ids): array
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (!$ids) {
return [];
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT anniversary_id, person_id FROM anniversary_people
WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id"
);
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['anniversary_id']][] = (int)$r['person_id'];
}
return $out;
}
public static function remove(int $familyId, int $id): void
{
Attachment::removeAll(self::ATTACH, $id);
// 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- ② 派生:名册里每个人的生日 ----------------
/**
* 把名册里所有人的生日,当成"纪念日"派生出来。
*
* 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西,
* 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。
*
* @return array<int,array> 每项含 title/kind/month/day/repeat_type/person_id/source/…
*/
public static function birthdays(int $familyId, int $viewerId): array
{
$people = Person::map($familyId);
$out = [];
foreach ($people as $pid => $p) {
$pid = (int)$pid;
// 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf)
$solar = Birthday::dateOnly(Birthday::solarOf($p));
if ($solar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的生日',
'kind' => '生日',
'repeat_type' => 'solar',
'month' => (int)substr($solar, 5, 2),
'day' => (int)substr($solar, 8, 2),
'is_leap' => 0,
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_solar',
'born' => $solar,
];
}
// 农历生日(**日期部分是农历**,所以月/日是农历的月日)
$lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? ''));
if ($lunar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的农历生日',
'kind' => '生日',
'repeat_type' => 'lunar',
'month' => (int)substr($lunar, 5, 2),
'day' => (int)substr($lunar, 8, 2),
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_lunar',
'born' => $lunar,
];
}
}
unset($viewerId);
return $out;
}
/** 自建纪念日 + 派生生日,合成一份"全部纪念日" */
public static function all(int $familyId, int $viewerId): array
{
$out = self::birthdays($familyId, $viewerId);
foreach (self::list($familyId) as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$out[] = $r;
}
return $out;
}
/**
* 两块分开(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」
*
* 所以返回 ['custom' => [...], 'derived' => [...]] 两块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* @return array{custom:array,derived:array}
*/
public static function split(int $familyId, int $viewerId): array
{
$rows = self::list($familyId);
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
$pmap = self::peopleMap(array_column($rows, 'id'));
$custom = [];
foreach ($rows as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$aid = (int)$r['id'];
if (!empty($pmap[$aid])) {
$r['person_ids'] = $pmap[$aid];
} elseif ((int)($r['person_id'] ?? 0) > 0) {
// 兜底:新表没有行就看老列(升级过渡期)
$r['person_ids'] = [(int)$r['person_id']];
} else {
$r['person_ids'] = [];
}
$custom[] = $r;
}
$derived = self::birthdays($familyId, $viewerId);
// 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路
foreach ($derived as &$dv) {
$dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : [];
}
unset($dv);
$byMD = static function (array $a, array $b): int {
return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0))
?: ((int)($a['day'] ?? 0) <=> (int)($b['day'] ?? 0))
?: strcmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
};
usort($custom, $byMD);
usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* @return string[] 有序、去重、非空
*/
public static function kinds(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::PRESET_KINDS; // 没配过 → 用预置那套
}
$out = [];
foreach ($dec as $k) {
$k = trim((string)$k);
if ($k !== '' && mb_strlen($k, 'UTF-8') <= 20 && !in_array($k, $out, true)) {
$out[] = $k;
}
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
foreach ($kinds as $k) {
$k = trim((string)$k);
if ($k === '' || mb_strlen($k, 'UTF-8') > 20) {
continue;
}
if (!in_array($k, $out, true)) {
$out[] = $k;
}
if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break;
}
}
Setting::set($familyId, 'anniv_kinds', json_encode($out, JSON_UNESCAPED_UNICODE));
}
/** 每个分类被用了几条(设置页要显示"这个分类有没有在用") */
public static function kindCounts(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT kind, COUNT(*) AS n FROM anniversaries
WHERE family_id = ? AND kind IS NOT NULL AND kind <> "" GROUP BY kind'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']] = (int)$r['n'];
}
return $out;
}
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
/**
* 某一年里,这些纪念日各落在哪个公历日期。
*
* ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月,
* 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同,
* 不存在"存一个公历日期年年用"的可能。
*
* @return array<int,array{date:string,title:string,days:int,kind:string,person_id:int,source:string,age:?int}>
*/
public static function occurrences(int $familyId, int $viewerId, int $year): array
{
$out = [];
foreach (self::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m < 1 || $m > 12 || $d < 1 || $d > 31) {
continue;
}
$date = null;
if ($repeat === 'lunar') {
$leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m;
$sol = Lunar::lunarToSolar($year, $m, $d, $leap);
if ($sol === null) {
// 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天
$max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m);
$sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap);
}
if ($sol === null) {
continue;
}
$date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
} else {
/* 🔴 v53:`once`(只此一次)**有年份时只在那一年出现**。
老行为是"落在公历分支 ⇒ 年年都冒出来",与"只此一次"自相矛盾;
以前没有年份,没法判"哪一年",只能那样漂着。
⚠️ 没有年份的 `once` 仍然保持老行为(不知道是哪一年,不能凭空丢掉)。 */
$onceYear = (int)($a['year'] ?? 0);
if ($repeat === 'once' && $onceYear > 0 && $onceYear !== $year) {
continue;
}
// 公历(含"只此一次"):2/29 落在平年时退到 2/28
$dd = $d;
if (!checkdate($m, $dd, $year)) {
$dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year)));
}
$date = sprintf('%04d-%02d-%02d', $year, $m, $dd);
}
$age = null;
if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) {
$age = Birthday::ageOn((string)$a['born'], $date);
}
/* 🔴 v53(用户 2026-10-16 第 1 条):算出**这是第几个纪念日**。
规则:`nth = 本次年份 - 起始年份 + 1`(起始那年就是"第 1 个")。
⚠️ 只用**用户自己填的** `year`(`birthdays()` 派生的生日条目没有这一项,
它们走 `age` 那套,别混)。没填年份 ⇒ null ⇒ 页面不显示序号。
⚠️ `nth < 1` 不显示(年份填到了未来 ⇒ 这一年的这次还没到"第 1 个")。 */
$y0 = (int)($a['year'] ?? 0);
// ⚠️ 派生的生日条目没有 year(它们走 age 那套),所以这里判 source
$nth = (($a['source'] ?? 'custom') === 'custom')
? self::nthFor($y0 > 0 ? $y0 : null, $year)
: null;
$out[] = [
'date' => $date,
'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''),
'person_id' => (int)($a['person_id'] ?? 0),
'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''),
'id' => (int)($a['id'] ?? 0),
'age' => $age,
'repeat' => $repeat,
'year' => $y0 > 0 ? $y0 : null, // 用户填的起始年(没有则 null)
'nth' => $nth, // 第几个(没有年份则 null)
];
}
return $out;
}
/**
* 一段时间内的纪念日(日历画格子用)。
* @return array<int,array> 按日期升序
*/
public static function between(int $familyId, int $viewerId, string $from, string $to): array
{
$y1 = (int)substr($from, 0, 4);
$y2 = (int)substr($to, 0, 4);
$out = [];
for ($y = $y1; $y <= $y2; $y++) {
foreach (self::occurrences($familyId, $viewerId, $y) as $o) {
if ($o['date'] >= $from && $o['date'] <= $to) {
$out[] = $o;
}
}
}
usort($out, static fn($a, $b) => strcmp($a['date'], $b['date']));
return $out;
}
/** 接下来 n 天内的纪念日(列表页头部"临近"用) */
public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days'));
$rows = self::between($familyId, $viewerId, $today, $to);
foreach ($rows as &$r) {
$r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400);
}
unset($r);
return $rows;
}
public static function repeatWord(string $k): string
{
return self::REPEAT[$k] ?? $k;
}
/**
* 🔴 v53(用户 2026-10-16 第 1 条):**这是第几个纪念日**。
*
* `nth = 年份 - 起始年 + 1`(起始那年就是"第 1 个");没有起始年(或算出来 < 1)返回 null。
*
* ⚠️ **单点来源**:`occurrences()`(临近区)与 `days/index.php`(我自己添加的那张表)
* 都调它 —— 两处各写一遍 `date('Y') - $year + 1` 是本项目反复踩过的
* "同一个概念两处计算"型坑(改一处忘一处 ⇒ 两个页面显示的序号能对不上)。
* ⚠️ 用**今天所在的年**("今年是第几个")而不是"下一次发生的那年"——
* 否则一个 12 月的纪念日在 1 月看会显示成"第 N+1 个",反直觉。
*/
public static function nthFor(?int $startYear, ?int $inYear = null): ?int
{
$y0 = (int)($startYear ?? 0);
if ($y0 <= 0) {
return null;
}
$y = $inYear !== null ? (int)$inYear : (int)date('Y');
$n = $y - $y0 + 1;
return $n >= 1 ? $n : null;
}
}