diff --git a/api/calendar_events.php b/api/calendar_events.php new file mode 100644 index 0000000..7e5e6f3 --- /dev/null +++ b/api/calendar_events.php @@ -0,0 +1,40 @@ + false, 'error' => 'Method not allowed'], 405); +} + +$people = normalizePeopleList(readJsonFile('people.json')); +requireActivePerson($people); + +$familySettings = loadFamilySettings(); +$from = isset($_GET['from']) ? trim((string) $_GET['from']) : ''; +$to = isset($_GET['to']) ? trim((string) $_GET['to']) : ''; + +if ($from === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { + [$from, $to] = hubCalendarDefaultAgendaRange($familySettings); +} else { + if ($to === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + sendJson(['success' => false, 'error' => 'to must be YYYY-MM-DD when from is set'], 400); + } +} + +if ($from > $to) { + sendJson(['success' => false, 'error' => 'from must be on or before to'], 400); +} + +[, $tzLocal] = familyHubCalendarContext($familySettings); +$today = familyHubTodayYmdInTz($tzLocal); +$events = hubCalendarAgendaEvents($from, $to, $people, $familySettings); + +sendJson([ + 'success' => true, + 'events' => $events, + 'rangeStart' => $from, + 'rangeEnd' => $to, + 'today' => $today, +]); diff --git a/api/family_settings_save.php b/api/family_settings_save.php index acabafe..dac3ff6 100644 --- a/api/family_settings_save.php +++ b/api/family_settings_save.php @@ -31,7 +31,17 @@ if (isset($body['currency_permanence'])) { $merged['currency_permanence'] = $p; } if (isset($body['timezone'])) { - $merged['timezone'] = trim((string) $body['timezone']); + $tz = trim((string) $body['timezone']); + if (!in_array($tz, familyHubUsTimezoneIdentifiers(), true)) { + sendJson(['success' => false, 'error' => 'Invalid timezone'], 400); + } + $merged['timezone'] = $tz; +} +if (array_key_exists('calendar_two_way_google', $body)) { + $merged['calendar_two_way_google'] = !empty($body['calendar_two_way_google']); +} +if (isset($body['calendar_bill_days'])) { + $merged['calendar_bill_days'] = normalizeCalendarBillDaysRaw($body['calendar_bill_days']); } if (isset($body['week_starts_on'])) { $w = (int) $body['week_starts_on']; @@ -41,6 +51,8 @@ if (isset($body['week_starts_on'])) { $merged['week_starts_on'] = $w; } +$merged = normalizeLoadedFamilySettings($merged); + if (!writeJsonFile('family_settings.json', $merged)) { sendJson(['success' => false, 'error' => 'Failed to save settings'], 500); } diff --git a/assets/css/style.css b/assets/css/style.css index b9872c0..20bab1c 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -101,4 +101,12 @@ body { margin-right: 0; margin-bottom: 5px; } -} \ No newline at end of file +} + +.calendar-agenda-list .calendar-agenda-day:first-child .list-group-item.border-top { + border-top: none !important; +} + +.calendar-agenda-today { + background-color: rgba(74, 144, 226, 0.06); +} diff --git a/assets/js/main.js b/assets/js/main.js index 822df47..c7ab4d3 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -179,7 +179,6 @@ currency_symbol: $('#currency_symbol').val(), currency_name: $('#currency_name').val(), currency_permanence: $('#currency_permanence').val(), - timezone: $('#timezone').val(), week_starts_on: parseInt($('#week_starts_on').val(), 10) }; postJson('/family_settings_save.php', payload) @@ -194,6 +193,53 @@ }); } + function collectBillDaysFromDom() { + var out = []; + $('#billDaysRows .bill-day-row').each(function () { + var $row = $(this); + var day = parseInt($row.find('.bill-day-dom').val(), 10); + var title = ($row.find('.bill-day-title').val() || '').trim(); + if (title !== '' && day >= 1 && day <= 31) { + out.push({ dayOfMonth: day, title: title }); + } + }); + return out; + } + + function bindCalendarSettingsForm() { + var $form = $('#calendarSettingsForm'); + if (!$form.length) { + return; + } + $('#btnAddBillDayRow').on('click', function () { + var $first = $('#billDaysRows .bill-day-row').first(); + if (!$first.length) { + return; + } + var $clone = $first.clone(); + $clone.find('.bill-day-dom').val('1'); + $clone.find('.bill-day-title').val(''); + $('#billDaysRows').append($clone); + }); + $form.on('submit', function (e) { + e.preventDefault(); + var payload = { + timezone: $('#family_timezone').val(), + calendar_two_way_google: $('#calendar_two_way_google').is(':checked'), + calendar_bill_days: collectBillDaysFromDom() + }; + postJson('/family_settings_save.php', payload) + .then(function () { + showAlert($('#calendarSettingsFeedback'), 'success', 'Saved.'); + $('#calendarSettingsFeedback').removeClass('d-none'); + }) + .catch(function (err) { + showAlert($('#calendarSettingsFeedback'), 'danger', err.message || 'Could not save'); + $('#calendarSettingsFeedback').removeClass('d-none'); + }); + }); + } + function choreListPayloadFromForm() { var type = $('#chore_list_type').val() || 'checkbox'; var raw = $('#chore_list_items').val() || ''; @@ -785,6 +831,7 @@ bindFirstHeadForm(); bindAddPersonForm(); bindFamilySettingsForm(); + bindCalendarSettingsForm(); bindPeopleTable(); bindChoresPage(); bindCurrencyPage(); diff --git a/includes/calendar_helpers.php b/includes/calendar_helpers.php new file mode 100644 index 0000000..b1d94db --- /dev/null +++ b/includes/calendar_helpers.php @@ -0,0 +1,289 @@ +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]; +} diff --git a/includes/family_settings.php b/includes/family_settings.php index 51746c8..1c092b5 100644 --- a/includes/family_settings.php +++ b/includes/family_settings.php @@ -2,22 +2,98 @@ require_once __DIR__ . '/db.php'; -function defaultFamilySettings(): array { +/** + * @return list + */ +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 + */ +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 + */ +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 $s + * @return array + */ +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); } /** diff --git a/readme.md b/readme.md index f17726a..d65b5d3 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ A centralized family organization system with tabs for chores, groceries, meal planning, family currency, and settings. ## Features -- Tabbed interface for chores, groceries, meals, shared Google Calendar embed, currency, and family settings (profiles, stores, preferences) +- Tabbed interface for chores, groceries, meals, combined Calendar (Family Hub agenda plus optional Google embed), currency, and family settings (profiles, stores, preferences) - JSON-based data storage under `data/` - Daily automated exports (`scripts/daily_export.php` and `export.php?type=…`) - Mobile-friendly interface @@ -72,9 +72,21 @@ The **Calendar** tab embeds your family calendar in the hub using these values ( | **`EXPORT_FREQUENCY`** | How often exports are expected to run | Your choice (e.g. `daily`). Match how you schedule **`scripts/daily_export.php`** in cron. | | **`EXPORT_RETENTION_DAYS`** | How long to keep export files (interpreted by app logic) | Integer number of days (e.g. `30`). | +## Calendar preferences (family settings) + +These are **not** in `.env`; they live in `data/family_settings.json` and are edited under **Family settings → Calendar and dates**. + +| Key / UI | Purpose | Notes | +| -------- | -------- | ----- | +| **Family timezone** | US IANA zone (e.g. `America/Denver`) | Dropdown of US zones only. Drives **today** and date ranges on the **Calendar** tab agenda. Options are the standard PHP US timezone identifiers (e.g. **America/***, **Pacific/Honolulu**). | +| **Enable two-way Google Calendar sync** | Opt-in for future OAuth sync with Google | Default off. When off, the Calendar tab uses the Google **embed** from `.env` (if set) and the local agenda only—no merge API. When on, the app may show sync messaging; full OAuth is not required for the embed. | +| **Monthly bill reminders** | `calendar_bill_days` | JSON array of `{ "dayOfMonth": 1–31, "title": "Rent" }`. Shown on the agenda on that calendar day each month. | + +**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone. + ## Directory Structure familyHub/ -├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, …) +├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, …) ├── assets/ # Static assets │ ├── css/ # CSS files │ │ └── style.css @@ -87,7 +99,7 @@ familyHub/ ├── data/ # JSON data storage (not tracked in git) │ ├── chores.json # Chores, assignments, submissions, reviews │ ├── people.json # Profiles, roles, PIN hashes, currency balances -│ ├── family_settings.json # Currency labels, timezone, week start, etc. +│ ├── family_settings.json # Currency labels, US timezone, week start, calendar merge flag, bill reminders, etc. │ ├── expenses.json # Head-of-household expense log │ ├── stores.json # Grocery store definitions │ ├── grocery_lists.json # Per-store shopping lines diff --git a/tabs/calendar.php b/tabs/calendar.php index 058711a..a28ec09 100644 --- a/tabs/calendar.php +++ b/tabs/calendar.php @@ -1,4 +1,8 @@ 'fa-tasks', + 'meal' => 'fa-utensils', + 'grocery_due' => 'fa-cart-shopping', + 'expense' => 'fa-coins', + 'bill_day' => 'fa-file-invoice-dollar', +]; ?>

Calendar

- -

Connect a shared family calendar by setting GOOGLE_CALENDAR_EMBED_CODE (embed URL or full <iframe> from Google Calendar) or GOOGLE_CALENDAR_ID in .env. See Google Calendar → calendar menu → Settings and sharingIntegrate calendar.

- - -

Showing calendar from GOOGLE_CALENDAR_ID. For more control (view, height), paste the embed URL or iframe into GOOGLE_CALENDAR_EMBED_CODE instead.

- -
- -
+ + +
+ Two-way Google Calendar sync is enabled in Family settings. OAuth connection is not available in this version yet. Your Google view below (if configured) and the Family Hub agenda still work; full sync will require signing in with Google when supported. +
+ +
+
+

Family Hub agenda

+

+ to + · Times use +

+ +

Nothing scheduled in this range. Add chores, meal plan slots, expenses, or bill reminders.

+ +
+ $dayEvents): ?> +
+
+ + Today · + + + + Past + +
+ + + + + + + + + + + + +
+ +
+ +
+
+

Google Calendar

+ +

Connect a shared family calendar by setting GOOGLE_CALENDAR_EMBED_CODE (embed URL or full <iframe> from Google Calendar) or GOOGLE_CALENDAR_ID in .env. See Google Calendar → calendar menu → Settings and sharingIntegrate calendar.

+ + +

Showing calendar from GOOGLE_CALENDAR_ID. For more control (view, height), paste the embed URL or iframe into GOOGLE_CALENDAR_EMBED_CODE instead.

+ +
+ +
+ +
+
diff --git a/tabs/settings.php b/tabs/settings.php index ec541a8..232e520 100644 --- a/tabs/settings.php +++ b/tabs/settings.php @@ -3,6 +3,13 @@ require_once __DIR__ . '/../includes/db.php'; require_once __DIR__ . '/../includes/utils.php'; require_once __DIR__ . '/../includes/persona.php'; require_once __DIR__ . '/../includes/grocery_helpers.php'; +require_once __DIR__ . '/../includes/family_settings.php'; + +$usTimezones = familyHubUsTimezoneIdentifiers(); +$billDays = $familySettings['calendar_bill_days'] ?? []; +if (!is_array($billDays) || $billDays === []) { + $billDays = [['dayOfMonth' => 1, 'title' => '']]; +} $canManage = count($people) === 0 || ( @@ -54,6 +61,73 @@ $permanenceOptions = [ +
+
Calendar and dates
+
+

Used for the Calendar tab agenda (today, due dates, and bill reminders). US timezones only.

+ 0): ?> +

Switch to a verified Head of household to edit these settings.

+ +
+
+ + +
+
+
+ + > + +
+
+
+ +

Shown on the family agenda on the same day each month (e.g. rent). Leave a row blank or clear the title to skip.

+
+ $bd): ?> + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+ +
+
+
Chore economy
@@ -83,12 +157,6 @@ $permanenceOptions = [
-
- - > -
- +