Calendar v2 - Combined Calendar Feature
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/chore_helpers.php';
|
||||
require_once __DIR__ . '/meal_helpers.php';
|
||||
require_once __DIR__ . '/grocery_helpers.php';
|
||||
require_once __DIR__ . '/expense_helpers.php';
|
||||
require_once __DIR__ . '/family_settings.php';
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: DateTimeZone}
|
||||
*/
|
||||
function familyHubCalendarContext(array $familySettings): array {
|
||||
$allowed = familyHubUsTimezoneIdentifiers();
|
||||
$tzId = normalizeTimezoneToUs((string) ($familySettings['timezone'] ?? ''), $allowed);
|
||||
try {
|
||||
$tz = new DateTimeZone($tzId);
|
||||
} catch (Exception $e) {
|
||||
$tz = new DateTimeZone('America/New_York');
|
||||
$tzId = 'America/New_York';
|
||||
}
|
||||
return [$tzId, $tz];
|
||||
}
|
||||
|
||||
function familyHubTodayYmdInTz(DateTimeZone $tzLocal): string {
|
||||
return (new DateTimeImmutable('now', $tzLocal))->format('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{dayOfMonth: int, title: string}> $billDayDefs
|
||||
* @return list<string> 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<int, array<string, mixed>> $people
|
||||
* @param array<string, mixed> $familySettings
|
||||
* @return list<array{type: string, date: string, title: string, detail: string, hrefQuery: string}>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
@@ -2,22 +2,98 @@
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
function defaultFamilySettings(): array {
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function familyHubUsTimezoneIdentifiers(): array {
|
||||
$ids = DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY, 'US');
|
||||
if (is_array($ids) && $ids !== []) {
|
||||
sort($ids);
|
||||
return array_values($ids);
|
||||
}
|
||||
return [
|
||||
'Pacific/Honolulu',
|
||||
'America/Anchorage',
|
||||
'America/Los_Angeles',
|
||||
'America/Phoenix',
|
||||
'America/Denver',
|
||||
'America/Chicago',
|
||||
'America/New_York',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function familySettingsDefaultsRaw(): array {
|
||||
return [
|
||||
'currency_symbol' => '★',
|
||||
'currency_name' => 'Stars',
|
||||
'currency_permanence' => 'permanent',
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => 'America/New_York',
|
||||
'week_starts_on' => 0,
|
||||
'calendar_two_way_google' => false,
|
||||
'calendar_bill_days' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return list<array{dayOfMonth: int, title: string}>
|
||||
*/
|
||||
function normalizeCalendarBillDaysRaw($raw): array {
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$d = isset($row['dayOfMonth']) ? (int) $row['dayOfMonth'] : 0;
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
if ($d < 1 || $d > 31 || $title === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['dayOfMonth' => $d, 'title' => $title];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function normalizeTimezoneToUs(string $tz, array $allowed): string {
|
||||
$tz = trim($tz);
|
||||
if ($tz !== '' && in_array($tz, $allowed, true)) {
|
||||
return $tz;
|
||||
}
|
||||
return 'America/New_York';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply defaults and normalize timezone / calendar fields after merge with stored JSON.
|
||||
*
|
||||
* @param array<string, mixed> $s
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeLoadedFamilySettings(array $s): array {
|
||||
$allowed = familyHubUsTimezoneIdentifiers();
|
||||
$s['timezone'] = normalizeTimezoneToUs((string) ($s['timezone'] ?? ''), $allowed);
|
||||
$v = $s['calendar_two_way_google'] ?? false;
|
||||
$s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true';
|
||||
$s['calendar_bill_days'] = normalizeCalendarBillDaysRaw($s['calendar_bill_days'] ?? []);
|
||||
return $s;
|
||||
}
|
||||
|
||||
function defaultFamilySettings(): array {
|
||||
return normalizeLoadedFamilySettings(familySettingsDefaultsRaw());
|
||||
}
|
||||
|
||||
function loadFamilySettings(): array {
|
||||
$raw = readJsonFile('family_settings.json');
|
||||
if (!is_array($raw)) {
|
||||
return defaultFamilySettings();
|
||||
$merged = familySettingsDefaultsRaw();
|
||||
if (is_array($raw)) {
|
||||
$merged = array_merge($merged, $raw);
|
||||
}
|
||||
return array_merge(defaultFamilySettings(), $raw);
|
||||
return normalizeLoadedFamilySettings($merged);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user