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:
parent
93ecc5910a
commit
df06dcc0d7
22
.cursor/rules/bootstrap-modals.mdc
Normal file
22
.cursor/rules/bootstrap-modals.mdc
Normal 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.
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ $iconByType = [
|
||||
'grocery_due' => 'fa-cart-shopping',
|
||||
'expense' => 'fa-coins',
|
||||
'bill_day' => 'fa-file-invoice-dollar',
|
||||
'birthday_reminder' => 'fa-cake-candles',
|
||||
];
|
||||
?>
|
||||
|
||||
|
||||
@ -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; ?>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user