Files
FamilyBook/app/Anniversary.php
T
laoyang 90973bb71c 纪念日:生日改成只读卡、自建纪念日才可编辑 (#87)
用户 2026-11-05 四条:
  1.「如果这个纪念日来自生日,我们是不能编辑的。」
  2.「点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名,
     以及生日的『日』和日期就可以了。」
  3.「如果是我们自己创建的纪念日,那么这里面就是编辑纪念日。」
  4.「这个框和刚才我们编辑任务的框是一样的做法。样式、格式全部都一样。」

🔴 先说一个**会改坏用户数据的 bug**(这次一并修掉):
   原来派生生日也走 FbDayEditSheet,而且传的是
     editingMonth: 1, editingDay: 1   ← 写死的 1 月 1 日
   结果点开任何一个生日都显示「1 月 1 日」,点保存还能把
   **成员档案里的真实生日覆盖成 1 月 1 日**。
   这不是样式问题,是数据损坏。

改动:

① 新增 components/FbDayBirthdaySheet.ets(派生生日的只读卡)
   · 外壳用 FbSheetRing + 遮罩点击关闭 + 右上角 FbSheetClose
     —— 与 FbTaskPanel / FbDayEditSheet 同一套版式(用户第 4 条)
   · 左上角「生日 / 农历生日」主题色粗体 + 一行小字
     「来自成员资料,不能在这里修改」——把"不能编辑"这条边界写明白,
     否则用户会以为卡片坏了(用户第 1 条)
   · 主体:FbAvatar(56) + 姓名(主题色粗体) + 完整日期
   · 「日」做成视觉焦点:84×84 浅主题色块里放 34 号大字数字 + 「11 月」
   · 右侧:还有几天 / 满 N 周岁 / 农历说明
   · 底部:「要修改生日,请到「家人」里打开这位成员的资料」
     —— 只读也要给出口,不然用户不知道去哪儿改

② FbHomePage 按 source 分发
   birthday_solar / birthday_lunar → FbDayBirthdaySheet(只读)
   custom                          → FbDayEditSheet(可编辑,原行为)
   ⚠️ 判据用 source 前缀而不是 id === 0:两者目前等价,但 source 是语义、
      id 是实现细节 —— 万一以后派生条目也带 id,用 id 判会静默错。

③ 服务端 Anniversary::occurrences() 补输出
   · person_name —— `birthdays()` 算出来了,但 occurrences 逐个字段挑着抄时漏了
   · avatar / avatar_color —— 只读卡要画头像(key,端上拼 URL)
   · day / month —— 用户要的「生日的『日』」
     ⚠️ 用 $a['day'] 而不是从 $date 截:农历生日的 day 是农历日
        (初五 = 5),date 是换算后的公历日 —— 两者不同,用户要看"生日那天"
   · birthdays() 同步补 avatar / avatar_color(它原来也没有)

④ Types / FbParse 补五个字段
   personName / avatar / avatarColor / day / month

编译通过,已装真机。
2026-11-05 20:32:10 +08:00

746 lines
34 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 = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
/**
* 预置分类各配哪个图标(v62)。
*
* ⚠️ 这张表只用于**兜底**:分类名 → 图标真正的来源是 settings 里那份
* `anniv_kinds`(用户可以在「分类设置」里逐条改)。这里的作用是
* 「老库升级上来、还没配过图标」时,六个预置分类不至于全变成同一个圆点。
* ⚠️ 值必须是 AnnivIcon 里存在的 key;认不出的会被 normalize 成 dot。
*/
public const PRESET_ICONS = [
'生日' => 'cake',
'结婚纪念日' => 'ring',
'相识纪念日' => 'heart',
'忌日' => 'candle',
'节日' => 'star',
'其它' => 'dot',
];
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'] ?? ''),
/* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'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'] ?? ''),
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'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' => [...]], 'hidden' => N 三块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* 🔴 v62(用户 2026-10-18 第 1 条):第一块从「我自己添加的」改叫「与我相关」,
* 原话:「这里要注意,如果是别人分享给我的,就是我是关联人,这里应该也要显示。」
* ⇒ 判据(**满足一条就留下**):
* ① 这条纪念日没填关联人 —— 它是"全家的事"(结婚纪念日、家庭节日…),
* 谁添加的都该看得到;
* ② 我是它的关联人之一 —— 别人添加的、把我挂上了,就该出现在我这儿。
* 反过来:别人给他自己家亲戚记的忌日(关联人只有那位亲戚)**不进我的列表** ——
* 那正是"与我无关"。这类条数放回 `hidden` 里,模板上给一句小字说明,
* 免得用户以为"数据丢了"(它们在日历里照旧出现)。
*
* ⚠️ 派生块(名册生日)**不过这个筛**:生日本来就已经是"与我相关"的结果集。
*
* @return array{custom:array,derived:array,hidden:int,hiddenIds: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 = [];
$hidden = 0;
/* 被筛掉的 id:页面上方那块「接下来的日子」也要跟着筛,否则出现
"上面列出来一条、下面两张表都找不到它"的自相矛盾(v62 实测踩到)。 */
$hiddenIds = [];
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'] = [];
}
// 见上面 ①②:只有"明确挂了别人、且没挂我"的才排除
if ($r['person_ids'] && $viewerId > 0
&& !in_array((int)$viewerId, array_map('intval', $r['person_ids']), true)) {
$hidden++;
$hiddenIds[] = $aid;
continue;
}
$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, 'hidden' => $hidden,
'hiddenIds' => $hiddenIds];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)—— **带图标**(v62)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* v62 追加:「每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删、可换图标);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* ⚠️ 存储形状变过一次(**必须兼容老数据**):
* v24 起:`["生日","结婚纪念日"]` —— 纯字符串数组
* v62 起:`[{"n":"生日","i":"cake"}, ...]` —— 名称 + 图标
* 读到字符串就按"没配图标"处理(用 PRESET_ICONS / 名字猜一个),
* 读到对象就取 n/i。**不改老库、不做一次性迁移**:读的时候兼容,写的时候
* 自然升级 —— 迁移脚本在"用户还没打开过设置页"时没有任何收益。
*
* @return array<int,array{name:string,icon:string}> 有序、去重、非空
*/
public static function kindsFull(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
// 没配过 → 用预置那套(每个都配好图标)
$out = [];
foreach (self::PRESET_KINDS as $k) {
$out[] = ['name' => $k, 'icon' => self::defaultIcon($k)];
}
return $out;
}
$out = [];
$seen = [];
foreach ($dec as $item) {
if (is_array($item)) {
$n = trim((string)($item['n'] ?? $item['name'] ?? ''));
$i = (string)($item['i'] ?? $item['icon'] ?? '');
} else {
$n = trim((string)$item);
$i = '';
}
if ($n === '' || mb_strlen($n, 'UTF-8') > 20 || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = ['name' => $n, 'icon' => $i !== '' ? AnnivIcon::normalize($i) : self::defaultIcon($n)];
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
/** 只要名字(老的调用点、下拉、`in_array` 判断都还用它) */
public static function kinds(int $familyId): array
{
return array_column(self::kindsFull($familyId), 'name');
}
/** 名字 → 图标(设置页之外的一切渲染都问这里) */
public static function kindIcons(int $familyId): array
{
$out = [];
foreach (self::kindsFull($familyId) as $r) {
$out[$r['name']] = $r['icon'];
}
return $out;
}
/**
* 某个分类该用哪个图标。
*
* ⚠️ 三条回落,顺序不能反:
* ① 清单里配过的 → 用它(用户自己选的);
* ② 清单里没有(分类被删了、或者是老数据里的 kind)→ 按名字猜(PRESET_ICONS);
* ③ 还猜不出 → dot。
* 这样"删掉一个分类"之后,那些老纪念日**仍然有图标**,不会变成一片空白。
*/
public static function iconOf(int $familyId, string $kind): string
{
$kind = trim($kind);
if ($kind === '') {
return AnnivIcon::FALLBACK;
}
static $cache = [];
if (!isset($cache[$familyId])) {
$cache[$familyId] = self::kindIcons($familyId);
}
if (isset($cache[$familyId][$kind])) {
return $cache[$familyId][$kind];
}
return self::defaultIcon($kind);
}
/** 按名字猜一个图标(预置表 → 关键词 → dot) */
public static function defaultIcon(string $kind): string
{
$kind = trim($kind);
if (isset(self::PRESET_ICONS[$kind])) {
return self::PRESET_ICONS[$kind];
}
// 关键词兜底:用户自己起的名字里带"生日""结婚"……也该有个像样的图标
$guess = [
'生日' => 'cake', '寿' => 'cake',
'结婚' => 'ring', '婚' => 'ring',
'相识' => 'heart', '恋爱' => 'heart', '爱' => 'heart',
'忌' => 'candle', '追思' => 'candle', '逝' => 'candle',
'节' => 'star', '庆祝' => 'star',
'满月' => 'baby', '出生' => 'baby', '孩子' => 'baby',
'搬家' => 'home', '乔迁' => 'home', '家' => 'home',
'毕业' => 'book', '升学' => 'book', '入学' => 'book',
'旅' => 'plane', '行' => 'plane',
'合影' => 'camera', '照片' => 'camera',
'音乐' => 'music', '演出' => 'music',
'奖' => 'trophy', '晋升' => 'trophy', '成就' => 'trophy',
'入职' => 'clock', '周年' => 'clock',
'花' => 'flower', '祝福' => 'flower',
'礼物' => 'gift',
];
foreach ($guess as $needle => $icon) {
if (mb_strpos($kind, $needle, 0, 'UTF-8') !== false) {
return $icon;
}
}
return AnnivIcon::FALLBACK;
}
/**
* 保存分类清单。
*
* @param array $kinds 元素可以是 string(老调用点:只要名字)或
* array{name,icon}(新:带图标)
*/
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
$seen = [];
foreach ($kinds as $k) {
if (is_array($k)) {
$name = trim((string)($k['name'] ?? $k['n'] ?? ''));
$icon = (string)($k['icon'] ?? $k['i'] ?? '');
} else {
$name = trim((string)$k);
$icon = '';
}
if ($name === '' || mb_strlen($name, 'UTF-8') > 20 || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$out[] = [
'n' => $name,
'i' => $icon !== '' ? AnnivIcon::normalize($icon) : self::defaultIcon($name),
];
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'] ?? ''),
/* v62:分类图标(key)—— 日历拿它拼 emoji 前缀,卡片拿它画 SVG。
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
'person_id' => (int)($a['person_id'] ?? 0),
/* 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日…
点击之后,显示这个人的头像、姓名,以及生日的『日』和日期就可以了。」
⇒ 端上要画头像,所以这里必须带出来。
原来 `birthdays()` 算出来的 `person_name` 在这里**被丢掉了**
—— `occurrences()` 逐个字段挑着抄,漏了它。
⇒ 一并补 `person_name` + `avatar` + `avatar_color`。
`avatar` 是 key(如 `avatars/2026xxx.jpg`),
端上自己拼完整 URL(与任务负责人头像同一口径)。
⚠️ `all()` 里自建纪念日那批的 `person_name` 是 `''`
(自建纪念日可能关联多人、也可能不关联人),
这里原样透传即可,端上"名字为空就不画头像"。 */
'person_name' => (string)($a['person_name'] ?? ''),
'avatar' => (string)($a['avatar'] ?? ''),
'avatar_color' => (string)($a['avatar_color'] ?? ''),
/* 「日」—— 用户明确要"生日的『日』"。
直接给整数,端上做"大字显示"(如「17」)。
⚠️ 用 `$a['day']` 而不是从 `$date` 里截:
`$a['day']` 是**用户填的那一天**(农历生日时是农历日),
`$date` 是换算出来的公历日期 —— 两者在农历生日上不同,
用户要看的是"生日那天"(农历初五就该显示 5)。 */
'day' => (int)($a['day'] ?? 0),
'month' => (int)($a['month'] ?? 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;
}
}