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; } /** * @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]; $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', ]; } } 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]; }