diff --git a/.cursor/rules/bootstrap-modals.mdc b/.cursor/rules/bootstrap-modals.mdc new file mode 100644 index 0000000..5c080d6 --- /dev/null +++ b/.cursor/rules/bootstrap-modals.mdc @@ -0,0 +1,22 @@ +--- +description: Bootstrap modal backdrop — avoid blocking forms +globs: +alwaysApply: true +--- + +## Bootstrap 5 modals (Family Hub) + +The default modal **backdrop** has repeatedly caused **stacking / pointer-event problems** where the dimmed overlay sits above the dialog and **blocks clicks and focus** on fields inside the modal. + +**For modals that contain forms or other interactive controls:** + +1. On the root `.modal` element, set **`data-bs-backdrop="false"`**. +2. When constructing the instance in JavaScript, pass **`{ backdrop: false }`** to `new bootstrap.Modal(element, …)` so behavior stays correct even if markup changes. + +Users can still close the dialog with **Close**, **Cancel**, or other explicit dismiss controls. + +**Existing examples in this repo:** `tabs/meals.php` (`mealSlotModal`, `mealImportModal`); `assets/js/main.js` (`mealSlotModal` uses `{ backdrop: false }`). + +Do **not** rely on the default backdrop for interactive modals unless you have verified it does not intercept input on the target devices/browsers. + +**Ancestors with `transform`:** Any non-`none` `transform` on a parent (e.g. `.card:hover { transform: translateY(...) }`) creates a **containing block** for `position: fixed`, so Bootstrap modals inside that subtree render relative to the transformed ancestor and appear clipped. Family Hub card hover lift uses **box-shadow only** (no translate) for this reason; do not reintroduce transform-based card hover without moving modals to `document.body` or an untransformed shell. diff --git a/assets/css/style.css b/assets/css/style.css index 8011d34..3458c43 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -341,9 +341,10 @@ body.header-tone-light .app-header-logo-link { } } +/* No translateY on card hover: transform creates a containing block and traps + position:fixed descendants (e.g. Bootstrap modals inside the card). */ @media (hover: hover) and (pointer: fine) { .family-hub-body main .card:hover { - transform: translateY(-2px); box-shadow: var(--fh-shadow-lift); } } diff --git a/assets/js/main.js b/assets/js/main.js index ef8df8d..4c6889c 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -177,6 +177,124 @@ }); } + function toggleEditPersonPinVisibility() { + var initial = $('#edit_person_initial_role').val() || ''; + var role = $('#edit_role').val() || ''; + var $wrap = $('#edit_pin_wrap'); + var $pin = $('#edit_pin'); + var needPin = role === 'head_of_household' && initial !== 'head_of_household'; + if (needPin) { + $wrap.show(); + $pin.prop('required', true); + } else { + $wrap.hide(); + $pin.prop('required', false); + $pin.val(''); + } + } + + function bindEditPersonForm() { + var $form = $('#editPersonForm'); + if (!$form.length) { + return; + } + + var peopleById = {}; + var $payloadEl = $('#settingsPeopleEditPayload'); + if ($payloadEl.length) { + try { + var plist = JSON.parse($payloadEl.text()); + if (Array.isArray(plist)) { + plist.forEach(function (row) { + if (row && row.id) { + peopleById[row.id] = row; + } + }); + } + } catch (ignoreErr) { + peopleById = {}; + } + } + + var editModal = null; + if (typeof bootstrap !== 'undefined') { + var modalEl = document.getElementById('editPersonModal'); + if (modalEl) { + editModal = new bootstrap.Modal(modalEl, { backdrop: false }); + } + } + + $('#edit_role').on('change', toggleEditPersonPinVisibility); + + $(document).on('click', '.btn-edit-person', function () { + var id = $(this).data('person-id'); + if (!id || !peopleById[id]) { + window.alert('Could not load this person for editing.'); + return; + } + var p = peopleById[id]; + $('#edit_person_id').val(p.id); + $('#edit_person_initial_role').val(p.role || ''); + $('#edit_name').val(p.name || ''); + $('#edit_role').val(p.role || 'child'); + var fc = String(p.favoriteColor || '').trim(); + if (!/^#[0-9A-Fa-f]{6}$/.test(fc)) { + fc = '#4a90e2'; + } + $('#edit_favoriteColor').val(fc); + $('#edit_birthday').val(String(p.birthday || '').trim()); + $('#edit_description').val(p.description || ''); + $('#edit_pin').val(''); + $('#editPersonFeedback').addClass('d-none').removeClass('alert-success alert-danger alert-warning').text(''); + toggleEditPersonPinVisibility(); + if (editModal) { + editModal.show(); + } + }); + + $form.on('submit', function (e) { + e.preventDefault(); + var id = ($('#edit_person_id').val() || '').trim(); + var initial = $('#edit_person_initial_role').val() || ''; + var role = $('#edit_role').val() || ''; + var name = ($('#edit_name').val() || '').trim(); + if (!id || name === '') { + return; + } + var needPin = role === 'head_of_household' && initial !== 'head_of_household'; + var pin = needPin ? ($('#edit_pin').val() || '') : ''; + if (needPin && pin.length < 4) { + showAlert($('#editPersonFeedback'), 'danger', 'PIN must be at least 4 characters for a new Head of household.'); + $('#editPersonFeedback').removeClass('d-none'); + return; + } + if (initial === 'head_of_household' && role !== 'head_of_household') { + if (!window.confirm('Demote this Head of household? Their PIN will be cleared until they are promoted again.')) { + return; + } + } + var payload = { + id: id, + name: name, + role: role, + favoriteColor: $('#edit_favoriteColor').val() || '#4a90e2', + birthday: ($('#edit_birthday').val() || '').trim(), + description: ($('#edit_description').val() || '').trim() + }; + if (needPin) { + payload.pin = pin; + } + postJson('/people_update.php', payload) + .then(function () { + window.location.reload(); + }) + .catch(function (err) { + showAlert($('#editPersonFeedback'), 'danger', err.message || 'Could not save changes'); + $('#editPersonFeedback').removeClass('d-none'); + }); + }); + } + function bindFamilySettingsForm() { var $form = $('#familySettingsForm'); if (!$form.length) { @@ -308,59 +426,11 @@ } function bindChoresPage() { + var $choresTab = $('#chores'); var $form = $('#choreForm'); - if (!$form.length) { - return; - } - - $('#chore_schedule').on('change', toggleChoreRecurrence); - toggleChoreRecurrence(); - - $form.on('submit', function (e) { - e.preventDefault(); - var assignees = []; - $('.chore-assignee:checked').each(function () { - assignees.push($(this).val()); - }); - var due = $('#chore_due_date').val() || ''; - var payload = { - id: ($('#chore_id').val() || '').trim(), - title: $('#chore_title').val(), - description: $('#chore_description').val(), - image: $('#chore_image').val(), - value: Number($('#chore_value').val()) || 0, - due_date: due, - schedule: $('#chore_schedule').val(), - recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7, - assignee_ids: assignees, - anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'), - lists: choreListPayloadFromForm() - }; - postJson('/chore_save.php', payload) - .then(function () { - window.location.href = window.location.pathname + '?tab=chores'; - }) - .catch(function (err) { - var $fb = $('#choreFormFeedback'); - showAlert($fb, 'danger', err.message || 'Could not save chore'); - $fb.removeClass('d-none'); - }); - }); - - $('#choreDeleteBtn').on('click', function () { - var id = $(this).data('id'); - if (!id || !window.confirm('Delete this chore?')) { - return; - } - postJson('/chore_delete.php', { id: id }) - .then(function () { - window.location.href = window.location.pathname + '?tab=chores'; - }) - .catch(function (err) { - window.alert(err.message || 'Could not delete'); - }); - }); + // Add/edit form exists only for HoH (or when editing). Cards and pending list still need handlers for everyone. + if ($choresTab.length) { $(document).on('click', '.btn-chore-submit', function () { var id = $(this).data('id'); if (!id || !window.confirm('Submit this chore as done? A Head of household will approve it before the reward is paid.')) { @@ -463,6 +533,59 @@ $('#chore_nfc_feedback_' + id).removeClass('d-none'); }); }); + } + + if (!$form.length) { + return; + } + + $('#chore_schedule').on('change', toggleChoreRecurrence); + toggleChoreRecurrence(); + + $form.on('submit', function (e) { + e.preventDefault(); + var assignees = []; + $('.chore-assignee:checked').each(function () { + assignees.push($(this).val()); + }); + var due = $('#chore_due_date').val() || ''; + var payload = { + id: ($('#chore_id').val() || '').trim(), + title: $('#chore_title').val(), + description: $('#chore_description').val(), + image: $('#chore_image').val(), + value: Number($('#chore_value').val()) || 0, + due_date: due, + schedule: $('#chore_schedule').val(), + recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7, + assignee_ids: assignees, + anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'), + lists: choreListPayloadFromForm() + }; + postJson('/chore_save.php', payload) + .then(function () { + window.location.href = window.location.pathname + '?tab=chores'; + }) + .catch(function (err) { + var $fb = $('#choreFormFeedback'); + showAlert($fb, 'danger', err.message || 'Could not save chore'); + $fb.removeClass('d-none'); + }); + }); + + $('#choreDeleteBtn').on('click', function () { + var id = $(this).data('id'); + if (!id || !window.confirm('Delete this chore?')) { + return; + } + postJson('/chore_delete.php', { id: id }) + .then(function () { + window.location.href = window.location.pathname + '?tab=chores'; + }) + .catch(function (err) { + window.alert(err.message || 'Could not delete'); + }); + }); } function groceryReloadKeepParams() { @@ -1256,6 +1379,7 @@ bindPersonaSwitcher(); bindFirstHeadForm(); bindAddPersonForm(); + bindEditPersonForm(); bindFamilySettingsForm(); bindCalendarSettingsForm(); bindPeopleTable(); diff --git a/includes/calendar_helpers.php b/includes/calendar_helpers.php index b1d94db..7f1b21a 100644 --- a/includes/calendar_helpers.php +++ b/includes/calendar_helpers.php @@ -75,6 +75,30 @@ function calendarBillReminderDatesInRange( 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 */ @@ -84,7 +108,7 @@ function hubCalendarEventSortCompare(array $a, array $b): int { if ($da !== $db) { return $da <=> $db; } - $rank = ['chore' => 0, 'meal' => 1, 'grocery_due' => 2, 'expense' => 3, 'bill_day' => 4]; + $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) { @@ -268,6 +292,60 @@ function hubCalendarAgendaEvents( } } + $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; } diff --git a/tabs/calendar.php b/tabs/calendar.php index 738ff5e..98f654a 100644 --- a/tabs/calendar.php +++ b/tabs/calendar.php @@ -77,6 +77,7 @@ $iconByType = [ 'grocery_due' => 'fa-cart-shopping', 'expense' => 'fa-coins', 'bill_day' => 'fa-file-invoice-dollar', + 'birthday_reminder' => 'fa-cake-candles', ]; ?> diff --git a/tabs/settings.php b/tabs/settings.php index 45f9205..29fa74d 100644 --- a/tabs/settings.php +++ b/tabs/settings.php @@ -354,6 +354,73 @@ $permanenceOptions = [
+ + (string) ($ep['id'] ?? ''), + 'name' => (string) ($ep['name'] ?? ''), + 'role' => (string) ($ep['role'] ?? ROLE_CHILD), + 'favoriteColor' => (string) ($ep['favoriteColor'] ?? '#4a90e2'), + 'birthday' => (string) ($ep['birthday'] ?? ''), + 'description' => (string) ($ep['description'] ?? ''), + ]; + } + ?> + + +Only a verified Head of household can add or edit people.
@@ -380,6 +447,7 @@ $permanenceOptions = [