Enhance person editing functionality and birthday reminders

- Added a modal for editing person details, including name, role, favorite color, birthday, and description.
- Implemented visibility toggling for PIN input based on role selection, ensuring security for Head of Household.
- Introduced birthday reminder functionality in the calendar, generating events for upcoming birthdays with appropriate notifications.
- Updated calendar and settings UI to accommodate new features, improving user experience and data management.
This commit is contained in:
Louis Whittington 2026-03-31 18:08:22 -05:00
parent 93ecc5910a
commit df06dcc0d7
6 changed files with 347 additions and 53 deletions

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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;
}

View File

@ -77,6 +77,7 @@ $iconByType = [
'grocery_due' => 'fa-cart-shopping',
'expense' => 'fa-coins',
'bill_day' => 'fa-file-invoice-dollar',
'birthday_reminder' => 'fa-cake-candles',
];
?>

View File

@ -354,6 +354,73 @@ $permanenceOptions = [
<div class="alert d-none" id="addPersonFeedback" role="status"></div>
</div>
</form>
<?php
$peopleEditPayload = [];
foreach ($people as $ep) {
$peopleEditPayload[] = [
'id' => (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'] ?? ''),
];
}
?>
<script type="application/json" id="settingsPeopleEditPayload"><?= json_encode($peopleEditPayload, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
<div class="modal fade" id="editPersonModal" tabindex="-1" aria-labelledby="editPersonModalLabel" aria-hidden="true" data-bs-backdrop="false">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<form id="editPersonForm">
<div class="modal-header">
<h3 class="modal-title h5" id="editPersonModalLabel">Edit person</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body row g-3">
<input type="hidden" name="id" id="edit_person_id" value="">
<input type="hidden" id="edit_person_initial_role" value="">
<div class="col-12">
<label class="form-label" for="edit_name">Name</label>
<input class="form-control" id="edit_name" name="name" required maxlength="120" autocomplete="name">
</div>
<div class="col-md-6">
<label class="form-label" for="edit_role">Role</label>
<select class="form-select" id="edit_role" name="role">
<option value="<?= ROLE_CHILD ?>">Child</option>
<option value="<?= ROLE_ADULT ?>">Adult</option>
<option value="<?= ROLE_HEAD ?>">Head of household</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="edit_favoriteColor">Favorite color</label>
<input type="color" class="form-control form-control-color" id="edit_favoriteColor" name="favoriteColor" value="#4a90e2">
</div>
<div class="col-12" id="edit_pin_wrap">
<label class="form-label" for="edit_pin">PIN for new Head of household (4+ characters)</label>
<input type="password" class="form-control" id="edit_pin" minlength="4" autocomplete="new-password">
</div>
<div class="col-md-6">
<label class="form-label" for="edit_birthday">Birthday (optional)</label>
<input type="date" class="form-control" id="edit_birthday" name="birthday" autocomplete="bday">
</div>
<div class="col-12">
<label class="form-label" for="edit_description">Notes (optional)</label>
<textarea class="form-control" id="edit_description" name="description" rows="2" maxlength="500" placeholder="Short note visible in household tools"></textarea>
</div>
<div class="col-12">
<div class="alert d-none" id="editPersonFeedback" role="status"></div>
</div>
</div>
<div class="modal-footer flex-wrap gap-2 justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<?php else: ?>
<p class="text-muted">Only a verified Head of household can add or edit people.</p>
<?php endif; ?>
@ -380,6 +447,7 @@ $permanenceOptions = [
<button type="button" class="btn btn-sm btn-outline-secondary btn-rotate-person-nfc-token" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>" data-name="<?= htmlspecialchars((string) ($p['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Rotate token</button>
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-person" data-person-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Edit</button>
<?php if (($p['role'] ?? '') === ROLE_HEAD): ?>
<button type="button" class="btn btn-sm btn-outline-secondary btn-reset-pin" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Reset PIN</button>
<?php endif; ?>