'每年(阳历)', '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 / person_id / leap * @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))); $kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8'); $note = trim((string)($d['note'] ?? '')); $pid = (int)($d['person_id'] ?? 0) ?: null; $leap = !empty($d['leap']) ? 1 : 0; if ($id > 0) { $db->prepare( 'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?, kind=?, note=?, person_id=? WHERE id=? AND family_id=?' )->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid, $id, $familyId]); return $id; } $db->prepare( 'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap, kind, note, person_id) VALUES (?,?,?,?,?,?,?,?,?)' )->execute([$familyId, $title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]); return (int)$db->lastInsertId(); } public static function remove(int $familyId, int $id): void { Attachment::removeAll(self::ATTACH, $id); Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?') ->execute([$id, $familyId]); } // ---------------- ② 派生:名册里每个人的生日 ---------------- /** * 把名册里所有人的生日,当成"纪念日"派生出来。 * * 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西, * 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。 * * @return 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; } // ---------------- ③ 这一年会落在哪几天(给日历用) ---------------- /** * 某一年里,这些纪念日各落在哪个公历日期。 * * ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月, * 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同, * 不存在"存一个公历日期年年用"的可能。 * * @return array */ 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 { // 公历(含"只此一次"):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); } $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, ]; } return $out; } /** * 一段时间内的纪念日(日历画格子用)。 * @return 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; } }