format('Y-m-d'); } /** * @param array $billDayDefs * @return list Y-m-d dates in range */ function calendarBillReminderDatesInRange( string $rangeStart, string $rangeEnd, array $billDayDefs, DateTimeZone $tzLocal ): array { if ($billDayDefs === [] || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeStart) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeEnd)) { return []; } try { $start = new DateTimeImmutable($rangeStart . ' 00:00:00', $tzLocal); $end = new DateTimeImmutable($rangeEnd . ' 23:59:59', $tzLocal); } catch (Exception $e) { return []; } if ($start > $end) { return []; } $out = []; $cur = $start->modify('first day of this month')->setTime(0, 0); $lastMonth = $end->modify('first day of this month')->setTime(0, 0); while ($cur <= $lastMonth) { $y = (int) $cur->format('Y'); $m = (int) $cur->format('n'); $daysInMonth = (int) $cur->format('t'); foreach ($billDayDefs as $bd) { $dom = (int) $bd['dayOfMonth']; if ($dom < 1) { continue; } $d = min($dom, $daysInMonth); $ymd = sprintf('%04d-%02d-%02d', $y, $m, $d); try { $dt = new DateTimeImmutable($ymd . ' 00:00:00', $tzLocal); } catch (Exception $e) { continue; } if ($dt >= $start && $dt <= $end) { $out[] = $ymd; } } $cur = $cur->modify('first day of next month'); } return $out; } /** * Calendar date for month/day in a given year (Feb 29 → Feb 28 on non-leap years). */ function hubCalendarBirthdayInYear(int $year, int $month, int $day, DateTimeZone $tzLocal): ?DateTimeImmutable { if ($month < 1 || $month > 12 || $day < 1 || $day > 31) { return null; } if (!checkdate($month, $day, $year)) { if ($month === 2 && $day === 29 && checkdate(2, 28, $year)) { try { return new DateTimeImmutable(sprintf('%04d-02-28', $year), $tzLocal); } catch (Exception $e) { return null; } } return null; } try { return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day), $tzLocal); } catch (Exception $e) { return null; } } /** * @return int negative if $a sorts before $b */ function hubCalendarEventSortCompare(array $a, array $b): int { $da = (string) ($a['date'] ?? ''); $db = (string) ($b['date'] ?? ''); if ($da !== $db) { return $da <=> $db; } $rank = ['chore' => 0, 'meal' => 1, 'grocery_due' => 2, 'expense' => 3, 'bill_day' => 4, 'birthday_reminder' => 5]; $ta = $rank[(string) ($a['type'] ?? '')] ?? 99; $tb = $rank[(string) ($b['type'] ?? '')] ?? 99; if ($ta !== $tb) { return $ta <=> $tb; } return strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? '')); } /** * @param array> $people * @param array $familySettings * @return list */ function hubCalendarAgendaEvents( string $rangeStart, string $rangeEnd, array $people, array $familySettings ): array { [$tzId, $tzLocal] = familyHubCalendarContext($familySettings); unset($tzId); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeStart) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $rangeEnd)) { return []; } $events = []; $nameById = []; foreach ($people as $p) { if (!empty($p['id'])) { $nameById[(string) $p['id']] = (string) ($p['name'] ?? ''); } } $sym = trim((string) ($familySettings['currency_symbol'] ?? '')); $rawChores = readJsonFile('chores.json'); $chores = migrateAllChores(normalizeChoresList($rawChores), $people); foreach ($chores as $c) { if (($c['status'] ?? '') !== 'active') { continue; } $due = trim((string) ($c['due_date'] ?? '')); if ($due === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $due)) { continue; } $inForwardWindow = ($due >= $rangeStart && $due <= $rangeEnd); $overdue = ($due < $rangeStart); if (!$inForwardWindow && !$overdue) { continue; } $title = trim((string) ($c['title'] ?? '')); if ($title === '') { $title = 'Chore'; } $assignees = []; foreach ($c['assignee_ids'] ?? [] as $aid) { $aid = (string) $aid; if ($aid !== '' && isset($nameById[$aid])) { $assignees[] = $nameById[$aid]; } } $detail = $assignees !== [] ? implode(', ', $assignees) : ''; if ($overdue) { $detail = trim('Overdue · ' . $detail, ' ·'); } $cid = (string) ($c['id'] ?? ''); $events[] = [ 'type' => 'chore', 'date' => $due, 'title' => $title, 'detail' => $detail, 'hrefQuery' => 'tab=chores' . ($cid !== '' ? '&edit=' . rawurlencode($cid) : ''), ]; } $meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json'))); $plan = loadMealPlan(); $weekStart = (string) ($plan['weekStart'] ?? ''); if ($weekStart !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $weekStart)) { foreach (mealSlotTypes() as $slot) { $slotLabel = ucfirst($slot); for ($i = 0; $i < 7; $i++) { $key = (string) $i; $mid = $plan['slots'][$key][$slot] ?? null; if ($mid === null || $mid === '') { continue; } $mid = (string) $mid; $meal = findMealById($meals, $mid); $mTitle = $meal !== null ? trim((string) ($meal['title'] ?? '')) : ''; if ($mTitle === '') { $mTitle = 'Meal'; } try { $d = (new DateTimeImmutable($weekStart . ' 00:00:00', $tzLocal))->modify('+' . $i . ' days')->format('Y-m-d'); } catch (Exception $e) { continue; } if ($d < $rangeStart || $d > $rangeEnd) { continue; } $events[] = [ 'type' => 'meal', 'date' => $d, 'title' => $mTitle, 'detail' => $slotLabel, 'hrefQuery' => 'tab=meals', ]; } } } $catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json')); foreach ($catalog as $row) { $nd = isset($row['nextDueDate']) ? trim((string) $row['nextDueDate']) : ''; if ($nd === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $nd)) { continue; } if ($nd < $rangeStart || $nd > $rangeEnd) { continue; } $n = trim((string) ($row['name'] ?? '')); if ($n === '') { $n = 'Grocery item'; } $events[] = [ 'type' => 'grocery_due', 'date' => $nd, 'title' => $n, 'detail' => 'Recurring catalog', 'hrefQuery' => 'tab=groceries', ]; } $expenses = normalizeExpensesList(readJsonFile('expenses.json')); foreach ($expenses as $ex) { $d = trim((string) ($ex['date'] ?? '')); if ($d === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) { continue; } if ($d < $rangeStart || $d > $rangeEnd) { continue; } $t = trim((string) ($ex['title'] ?? '')); if ($t === '') { $t = 'Expense'; } $val = isset($ex['value']) && is_numeric($ex['value']) ? round((float) $ex['value'], 2) : null; $aid = (string) ($ex['assignee_id'] ?? ''); $an = $aid !== '' ? ($nameById[$aid] ?? '') : ''; $detail = $val !== null ? number_format($val, 2, '.', '') . ($sym !== '' ? ' ' . $sym : '') : ''; if ($an !== '') { $detail = trim($detail . ' · ' . $an, ' ·'); } $events[] = [ 'type' => 'expense', 'date' => $d, 'title' => $t, 'detail' => $detail, 'hrefQuery' => 'tab=currency', ]; } $billDefs = normalizeCalendarBillDaysRaw($familySettings['calendar_bill_days'] ?? []); foreach ($billDefs as $bd) { $title = trim((string) ($bd['title'] ?? '')); if ($title === '') { continue; } $single = [$bd]; foreach (calendarBillReminderDatesInRange($rangeStart, $rangeEnd, $single, $tzLocal) as $ymd) { $events[] = [ 'type' => 'bill_day', 'date' => $ymd, 'title' => $title, 'detail' => 'Bill reminder', 'hrefQuery' => 'tab=currency', ]; } } $startYear = (int) substr($rangeStart, 0, 4); $endYear = (int) substr($rangeEnd, 0, 4); foreach ($people as $p) { $nm = trim((string) ($p['name'] ?? '')); $bdRaw = trim((string) ($p['birthday'] ?? '')); if ($nm === '' || !preg_match('/^\d{4}-(\d{2})-(\d{2})$/', $bdRaw, $bm)) { continue; } $mm = (int) $bm[1]; $dd = (int) $bm[2]; for ($y = $startYear; $y <= $endYear; $y++) { $bday = hubCalendarBirthdayInYear($y, $mm, $dd, $tzLocal); if ($bday === null) { continue; } $bYmd = $bday->format('Y-m-d'); if ($bYmd < $rangeStart || $bYmd > $rangeEnd) { continue; } $idealReminderYmd = $bday->modify('-7 days')->format('Y-m-d'); $eventYmd = max($rangeStart, $idealReminderYmd); if ($eventYmd > $rangeEnd) { continue; } try { $evDt = new DateTimeImmutable($eventYmd . ' 00:00:00', $tzLocal); } catch (Exception $e) { continue; } $interval = $evDt->diff($bday); if ($interval->invert === 1) { continue; } $daysUntil = (int) $interval->days; $bdayLabel = $bday->format('F j'); if ($daysUntil === 0) { $title = $nm . '\'s birthday today'; } elseif ($daysUntil === 1) { $title = $nm . '\'s birthday tomorrow'; } elseif ($daysUntil === 7) { $title = $nm . '\'s birthday in one week'; } else { $title = $nm . '\'s birthday in ' . $daysUntil . ' days'; } $events[] = [ 'type' => 'birthday_reminder', 'date' => $eventYmd, 'title' => $title, 'detail' => 'Birthday on ' . $bdayLabel, 'hrefQuery' => 'tab=settings', ]; } } usort($events, 'hubCalendarEventSortCompare'); return $events; } /** * Default two-week window from today in family timezone. * * @return array{0: string, 1: string} rangeStart, rangeEnd */ function hubCalendarDefaultAgendaRange(array $familySettings): array { [, $tzLocal] = familyHubCalendarContext($familySettings); $today = familyHubTodayYmdInTz($tzLocal); try { $end = (new DateTimeImmutable($today . ' 00:00:00', $tzLocal))->modify('+13 days')->format('Y-m-d'); } catch (Exception $e) { $end = $today; } return [$today, $end]; }