First pass of features

This commit is contained in:
Louis Whittington 2026-03-30 14:53:13 -05:00
parent 1a65e69d25
commit 6353f9c9b4
54 changed files with 5409 additions and 128 deletions

View File

@ -0,0 +1,23 @@
---
description: Keep env.example and readme.md documentation in sync
globs: env.example,readme.md,**/env.example,**/readme.md
alwaysApply: true
---
## env.example and readme.md
The tracked environment template is `env.example` at the project root (the install wizard parses it; `.env` is local and gitignored).
**Whenever you add, remove, or rename a variable in `env.example`, you must update `readme.md`** — specifically the **Environment variables** (or equivalent) section.
For each key in `env.example`, the readme must explain:
- What the value is for
- **Where and how a user should obtain** that value (which product UI, console path, or short steps). Prefer stable patterns such as “Google Cloud Console → APIs & Services → Credentials” rather than fragile deep links where possible; official docs links are fine.
**Also update the readme when:**
- Install or env-related behavior changes (e.g. `install.php`, redirects, CLI vs web)
- New sections in `env.example` (mirror with a readme subsection and user-facing guidance)
Do not leave new `env.example` keys undocumented in the readme. The install wizard only reflects `env.example`; human-readable acquisition steps live in the readme.

1
.gitignore vendored
View File

@ -11,6 +11,7 @@
.env
.env.*
!.env.example
!env.example
# IDE files
.idea/

60
AGENTS.md Normal file
View File

@ -0,0 +1,60 @@
# Family Hub development agents
Three roles coordinate so features stay usable on phones and desktops, data stays consistent, and releases are verifiable without adding Composer or npm tooling.
## Roles
### UI agent
**Owns**
- Responsive layout (Bootstrap breakpoints), readable typography, and spacing consistent with [assets/css/style.css](assets/css/style.css).
- Touch targets (about 44px minimum where practical) and hover/keyboard affordances on desktop.
- Empty states, loading/error feedback for API actions, and color contrast when applying per-person `favoriteColor` theming.
- Information architecture for tabs (e.g. store sub-nav in groceries): scannable labels, scroll behavior on small screens.
**Definition of done**
- No horizontal overflow on a 320px-wide viewport for primary flows.
- Primary actions visible without reliance on hover-only tooltips.
### Engineering agent
**Owns**
- JSON schemas in `data/`, [`includes/db.php`](includes/db.php) read/write patterns (including locking), and `api/*.php` contracts.
- Session and persona rules: who can switch to Head of Household (PIN), how PINs are stored (`password_hash` only), and never exposing secrets in HTML or JSON responses.
- Migrations from older flat JSON shapes when fields are added; documenting new filenames in export/cron if applicable.
**Definition of done**
- Writes use exclusive file locks; reads that must be consistent use shared locks where implemented.
- Invalid `tab=` values cannot include arbitrary files ([`index.php`](index.php)).
- API errors return JSON with a stable `error` string; success responses are predictable for the UI agent.
### Testing agent
**Owns**
- Manual test matrices per feature (happy path, validation failures, mobile width).
- Optional plain-PHP smoke scripts under `scripts/` (no PHPUnit dependency).
- Browser checks for critical flows: persona switch, HoH PIN failure/success, settings save.
**Definition of done**
- Every feature merged with a short checklist in the PR description or a linked note (until automated smoke tests exist).
- Regression entries added when bugs are fixed.
## How the agents work together
1. **Engineering** lands data model and API first (or in the same PR as minimal UI).
2. **UI** wires the flow against real endpoints; adjusts copy and layout.
3. **Testing** runs the matrix; block merge on open P0/P1 gaps.
**Conflict resolution:** If a shortcut breaks responsive or locking rules, Engineering revises the approach; if the API is awkward for users, UI and Engineering agree on a small contract change before adding UI polish.
## Conventions
- PHP: naming per [.cursor/rules/naming-conventions.mdc](.cursor/rules/naming-conventions.mdc).
- Front-end libraries: Bootstrap, Font Awesome, and jQuery from CDN only (project rule).
- Runtime data lives in gitignored `data/`; committed examples belong under `fixtures/` or `docs/samples/`, not `data/`.

40
api/chore_delete.php Normal file
View File

@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
$chores = migrateAllChores($rawChores, $people);
$idx = findChoreIndexById($chores, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
}
$existing = $chores[$idx];
$isAuthor = ($existing['author_id'] ?? '') === ($actor['id'] ?? '');
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
if (!$isAuthor && !$isHoH) {
sendJson(['success' => false, 'error' => 'You cannot delete this chore'], 403);
}
array_splice($chores, $idx, 1);
if (!writeJsonFile('chores.json', $chores)) {
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
}
sendJson(['success' => true]);

87
api/chore_review.php Normal file
View File

@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
}
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
$decision = isset($body['decision']) ? trim((string) $body['decision']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
if (!in_array($decision, ['approve', 'reject'], true)) {
sendJson(['success' => false, 'error' => 'decision must be approve or reject'], 400);
}
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
$chores = migrateAllChores($rawChores, $people);
$idx = findChoreIndexById($chores, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
}
$row = $chores[$idx];
$pending = $row['pending_submission'] ?? null;
if (!is_array($pending)) {
sendJson(['success' => false, 'error' => 'Nothing is waiting for approval on this chore'], 400);
}
if ($decision === 'reject') {
$row['pending_submission'] = null;
$chores[$idx] = migrateLegacyChoreRow($row, $people);
if (!writeJsonFile('chores.json', $chores)) {
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
}
sendJson(['success' => true]);
}
$assignees = $row['assignee_ids'] ?? [];
if (!is_array($assignees)) {
$assignees = [];
}
$value = (float) ($row['value'] ?? 0);
$n = count($assignees);
$share = $n > 0 ? round($value / $n, 2) : 0.0;
$row['pending_submission'] = null;
if (($row['schedule'] ?? CHORE_SCHEDULE_ONCE) === CHORE_SCHEDULE_RECURRING) {
$days = (int) ($row['recurrence_days'] ?? 7);
$days = max(1, $days);
$row['due_date'] = gmdate('Y-m-d', time() + ($days * 86400));
$row['status'] = 'active';
} else {
$row['status'] = 'completed';
}
$chores[$idx] = migrateLegacyChoreRow($row, $people);
if (!writeJsonFile('chores.json', $chores)) {
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
}
foreach ($people as $pi => $p) {
$pid = (string) ($p['id'] ?? '');
if ($pid === '' || !in_array($pid, $assignees, true)) {
continue;
}
$bal = $p['currency_balance'] ?? 0;
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $share;
}
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
}
sendJson(['success' => true, 'credited_each' => $share]);

97
api/chore_save.php Normal file
View File

@ -0,0 +1,97 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
$body = readJsonBody();
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
$chores = migrateAllChores($rawChores, $people);
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id !== '') {
$idx = findChoreIndexById($chores, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
}
$existing = $chores[$idx];
$isAuthor = ($existing['author_id'] ?? '') === ($actor['id'] ?? '');
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
if (!$isAuthor && !$isHoH) {
sendJson(['success' => false, 'error' => 'You cannot edit this chore'], 403);
}
$row = $existing;
} else {
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can create chores'], 403);
}
$row = [
'id' => bin2hex(random_bytes(8)),
'author_id' => (string) ($actor['id'] ?? ''),
'pending_submission' => null,
'status' => 'active',
];
$idx = null;
}
$title = isset($body['title']) ? trim((string) $body['title']) : '';
if ($title === '') {
sendJson(['success' => false, 'error' => 'Title is required'], 400);
}
$assigneeIds = $body['assignee_ids'] ?? [];
if (!is_array($assigneeIds)) {
$assigneeIds = [];
}
$assigneeIdsClean = [];
foreach ($assigneeIds as $aid) {
$aid = trim((string) $aid);
if ($aid !== '') {
$assigneeIdsClean[] = $aid;
}
}
$assigneeIdsClean = array_values(array_unique($assigneeIdsClean));
if (!choreAssigneeIdsValid($assigneeIdsClean, $people)) {
sendJson(['success' => false, 'error' => 'One or more assignees are invalid'], 400);
}
$row['title'] = $title;
$row['description'] = isset($body['description']) ? trim((string) $body['description']) : '';
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
$row['assignee_ids'] = $assigneeIdsClean;
$val = isset($body['value']) ? $body['value'] : 0;
$row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0;
$due = isset($body['due_date']) ? trim((string) $body['due_date']) : '';
if ($due !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $due)) {
sendJson(['success' => false, 'error' => 'due_date must be YYYY-MM-DD'], 400);
}
$row['due_date'] = $due;
$sched = isset($body['schedule']) ? (string) $body['schedule'] : CHORE_SCHEDULE_ONCE;
$row['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
$rd = isset($body['recurrence_days']) ? (int) $body['recurrence_days'] : ($row['recurrence_days'] ?? 7);
$row['recurrence_days'] = max(1, $rd);
if ($idx === null) {
$chores[] = migrateLegacyChoreRow($row, $people);
} else {
$chores[$idx] = migrateLegacyChoreRow($row, $people);
}
if (!writeJsonFile('chores.json', $chores)) {
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
}
sendJson(['success' => true, 'chore' => $row]);

59
api/chore_submit.php Normal file
View File

@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
$actorId = (string) ($actor['id'] ?? '');
if ($actorId === '') {
sendJson(['success' => false, 'error' => 'Invalid session'], 403);
}
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
$chores = migrateAllChores($rawChores, $people);
$idx = findChoreIndexById($chores, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
}
$row = $chores[$idx];
if (($row['status'] ?? '') !== 'active') {
sendJson(['success' => false, 'error' => 'This chore is not active'], 400);
}
$assignees = $row['assignee_ids'] ?? [];
if (!is_array($assignees) || !in_array($actorId, $assignees, true)) {
sendJson(['success' => false, 'error' => 'Only assignees can mark this chore complete'], 403);
}
if (!empty($row['pending_submission']) && is_array($row['pending_submission'])) {
sendJson(['success' => false, 'error' => 'This chore is already waiting for approval'], 400);
}
$note = isset($body['note']) ? trim((string) $body['note']) : '';
$row['pending_submission'] = [
'submitted_at' => gmdate('c'),
'submitted_by' => $actorId,
'note' => $note,
];
$chores[$idx] = migrateLegacyChoreRow($row, $people);
if (!writeJsonFile('chores.json', $chores)) {
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
}
sendJson(['success' => true]);

88
api/expense_create.php Normal file
View File

@ -0,0 +1,88 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/expense_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can record expenses'], 403);
}
$creatorId = (string) ($actor['id'] ?? '');
$body = readJsonBody();
$title = isset($body['title']) ? trim((string) $body['title']) : '';
$description = isset($body['description']) ? trim((string) $body['description']) : '';
$date = isset($body['date']) ? trim((string) $body['date']) : '';
$assigneeId = isset($body['assignee_id']) ? trim((string) $body['assignee_id']) : '';
if ($title === '') {
sendJson(['success' => false, 'error' => 'Title is required'], 400);
}
if ($date === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
sendJson(['success' => false, 'error' => 'date must be YYYY-MM-DD'], 400);
}
if ($assigneeId === '') {
sendJson(['success' => false, 'error' => 'assignee_id is required'], 400);
}
$valRaw = $body['value'] ?? null;
if (!is_numeric($valRaw)) {
sendJson(['success' => false, 'error' => 'value must be a number'], 400);
}
$value = round((float) $valRaw, 2);
if ($value <= 0) {
sendJson(['success' => false, 'error' => 'value must be greater than zero'], 400);
}
$targetIdx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $assigneeId) {
$targetIdx = $i;
break;
}
}
if ($targetIdx === null) {
sendJson(['success' => false, 'error' => 'Assignee not found'], 400);
}
$bal = $people[$targetIdx]['currency_balance'] ?? 0;
$bal = is_numeric($bal) ? (float) $bal : 0.0;
if ($bal < $value) {
sendJson([
'success' => false,
'error' => 'Insufficient balance: has ' . number_format($bal, 2, '.', '') . ', expense is ' . number_format($value, 2, '.', ''),
], 400);
}
$people[$targetIdx]['currency_balance'] = round($bal - $value, 2);
$expense = [
'id' => bin2hex(random_bytes(8)),
'title' => $title,
'description' => $description,
'date' => $date,
'value' => $value,
'assignee_id' => $assigneeId,
'created_at' => gmdate('c'),
'created_by' => $creatorId,
];
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
$expenses[] = $expense;
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to update balance'], 500);
}
if (!writeJsonFile('expenses.json', $expenses)) {
$people[$targetIdx]['currency_balance'] = $bal;
writeJsonFile('people.json', $people);
sendJson(['success' => false, 'error' => 'Failed to save expense; balance was not changed'], 500);
}
sendJson(['success' => true, 'expense' => $expense, 'new_balance' => $people[$targetIdx]['currency_balance']]);

View File

@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
if (count($people) > 0) {
assertHoHCanManagePeople($people);
}
$body = readJsonBody();
$merged = loadFamilySettings();
$allowedPermanence = ['permanent', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
if (isset($body['currency_symbol'])) {
$merged['currency_symbol'] = trim((string) $body['currency_symbol']);
}
if (isset($body['currency_name'])) {
$merged['currency_name'] = trim((string) $body['currency_name']);
}
if (isset($body['currency_permanence'])) {
$p = (string) $body['currency_permanence'];
if (!in_array($p, $allowedPermanence, true)) {
sendJson(['success' => false, 'error' => 'Invalid currency_permanence'], 400);
}
$merged['currency_permanence'] = $p;
}
if (isset($body['timezone'])) {
$merged['timezone'] = trim((string) $body['timezone']);
}
if (isset($body['week_starts_on'])) {
$w = (int) $body['week_starts_on'];
if ($w < 0 || $w > 6) {
sendJson(['success' => false, 'error' => 'week_starts_on must be 06'], 400);
}
$merged['week_starts_on'] = $w;
}
if (!writeJsonFile('family_settings.json', $merged)) {
sendJson(['success' => false, 'error' => 'Failed to save settings'], 500);
}
sendJson(['success' => true, 'settings' => $merged]);

View File

@ -0,0 +1,78 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
if (count($stores) === 0) {
sendJson(['success' => false, 'error' => 'No stores available'], 400);
}
$body = readJsonBody();
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
if ($storeId === '' || findStoreById($stores, $storeId) === null) {
sendJson(['success' => false, 'error' => 'Invalid store'], 400);
}
$name = isset($body['name']) ? trim((string) $body['name']) : '';
$description = isset($body['description']) ? trim((string) $body['description']) : '';
$size = isset($body['size']) ? trim((string) $body['size']) : '';
$quantity = isset($body['quantity']) ? trim((string) $body['quantity']) : '1';
$price = isset($body['price']) ? trim((string) $body['price']) : '';
$image = isset($body['image']) ? trim((string) $body['image']) : '';
$source = isset($body['source']) ? (string) $body['source'] : 'manual';
if (!in_array($source, ['manual', 'meal_plan', 'pending_review'], true)) {
$source = 'manual';
}
$recurring = isset($body['recurringIntervalDays']) ? max(0, (int) $body['recurringIntervalDays']) : 0;
$catalogPickId = isset($body['catalogPickId']) ? trim((string) $body['catalogPickId']) : '';
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
if ($catalogPickId !== '') {
$pick = findCatalogById($catalog, $catalogPickId);
if ($pick !== null && (string) ($pick['storeId'] ?? '') === $storeId) {
if ($name === '') {
$name = trim((string) ($pick['name'] ?? ''));
}
if ($description === '') {
$description = trim((string) ($pick['description'] ?? ''));
}
if ($size === '') {
$size = trim((string) ($pick['defaultSize'] ?? ''));
}
if ($image === '') {
$image = trim((string) ($pick['defaultImage'] ?? ''));
}
}
}
$result = groceryAppendShoppingLine(
$stores,
$storeId,
$name,
$description,
$size,
$quantity,
$price,
$image,
$source,
$recurring,
null,
null
);
if (!$result['ok']) {
sendJson(['success' => false, 'error' => $result['error'] ?? 'Failed'], 400);
}
sendJson(['success' => true, 'item' => $result['item']]);

View File

@ -0,0 +1,39 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
$body = readJsonBody();
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
if ($storeId === '' || $itemId === '') {
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$items = $lists['byStore'][$storeId] ?? [];
$next = [];
foreach ($items as $it) {
if (($it['id'] ?? '') === $itemId) {
continue;
}
$next[] = $it;
}
if (count($next) === count($items)) {
sendJson(['success' => false, 'error' => 'Item not found'], 404);
}
$lists['byStore'][$storeId] = $next;
if (!writeJsonFile('grocery_lists.json', $lists)) {
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
}
sendJson(['success' => true]);

View File

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
$body = readJsonBody();
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
$purchased = !empty($body['purchased']);
if ($storeId === '' || $itemId === '') {
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$items = $lists['byStore'][$storeId] ?? [];
$idx = null;
foreach ($items as $i => $it) {
if (($it['id'] ?? '') === $itemId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Item not found'], 404);
}
$row = $items[$idx];
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
if ($purchased) {
$row['status'] = 'purchased';
$row['purchasedAt'] = gmdate('c');
$cid = $row['catalogId'] ?? null;
if (is_string($cid) && $cid !== '') {
foreach ($catalog as $ci => $c) {
if (($c['id'] ?? '') === $cid) {
$catalog[$ci]['lastPurchaseAt'] = gmdate('c');
$days = max(0, (int) ($row['recurringIntervalDays'] ?? 0));
if ($days > 0) {
$catalog[$ci]['recurringIntervalDays'] = $days;
$catalog[$ci]['nextDueDate'] = gmdate('Y-m-d', strtotime('+' . $days . ' days'));
}
break;
}
}
}
} else {
$row['status'] = 'active';
$row['purchasedAt'] = null;
}
$row = normalizeGroceryLineItem($row);
$items[$idx] = $row;
$lists['byStore'][$storeId] = $items;
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
sendJson(['success' => false, 'error' => 'Failed to save catalog'], 500);
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
}
sendJson(['success' => true]);

View File

@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can approve pending items'], 403);
}
$body = readJsonBody();
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
if ($storeId === '' || $itemId === '') {
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$items = $lists['byStore'][$storeId] ?? [];
$idx = null;
foreach ($items as $i => $it) {
if (($it['id'] ?? '') === $itemId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Item not found'], 404);
}
$row = $items[$idx];
if (($row['status'] ?? '') !== 'pending_review') {
sendJson(['success' => false, 'error' => 'Item is not pending review'], 400);
}
$row['status'] = 'active';
$row = normalizeGroceryLineItem($row);
$items[$idx] = $row;
$lists['byStore'][$storeId] = $items;
if (!writeJsonFile('grocery_lists.json', $lists)) {
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
}
sendJson(['success' => true]);

39
api/meal_delete.php Normal file
View File

@ -0,0 +1,39 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
$actorId = (string) ($actor['id'] ?? '');
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
$idx = findMealIndexById($meals, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
}
$existing = $meals[$idx];
$isAuthor = ($existing['author_id'] ?? '') === $actorId;
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
if (!$isAuthor && !$isHoH) {
sendJson(['success' => false, 'error' => 'You cannot delete this meal'], 403);
}
array_splice($meals, $idx, 1);
if (!writeJsonFile('meals.json', $meals)) {
sendJson(['success' => false, 'error' => 'Failed to save meals'], 500);
}
sendJson(['success' => true]);

View File

@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
$body = readJsonBody();
$mealId = isset($body['mealId']) ? trim((string) $body['mealId']) : '';
$ingredient = isset($body['ingredient']) ? trim((string) $body['ingredient']) : '';
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
if ($ingredient === '') {
sendJson(['success' => false, 'error' => 'ingredient is required'], 400);
}
if (count($stores) === 0) {
sendJson(['success' => false, 'error' => 'Add a grocery store first'], 400);
}
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
$meal = $mealId !== '' ? findMealById($meals, $mealId) : null;
$mealTitle = $meal !== null ? (string) ($meal['title'] ?? '') : '';
if ($storeId === '' || findStoreById($stores, $storeId) === null) {
$storeId = groceryFirstStoreId($stores);
}
if ($storeId === '') {
sendJson(['success' => false, 'error' => 'No valid store'], 400);
}
$res = groceryAppendShoppingLine(
$stores,
$storeId,
$ingredient,
$mealTitle !== '' ? 'From meal: ' . $mealTitle : 'From meal ingredient',
'',
'1',
'',
'',
'meal_plan',
0,
$mealId !== '' ? $mealId : null,
$mealTitle !== '' ? $mealTitle : null
);
if (!$res['ok']) {
sendJson(['success' => false, 'error' => $res['error'] ?? 'Failed'], 400);
}
sendJson(['success' => true, 'item' => $res['item']]);

View File

@ -0,0 +1,68 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
$body = readJsonBody();
$weekStart = isset($body['weekStart']) ? trim((string) $body['weekStart']) : '';
$day = isset($body['day']) ? (int) $body['day'] : -1;
$mealType = isset($body['mealType']) ? trim((string) $body['mealType']) : '';
$mealId = isset($body['mealId']) ? trim((string) $body['mealId']) : '';
$mealId = $mealId === '' ? null : $mealId;
$pushGrocery = array_key_exists('pushGrocery', $body) ? !empty($body['pushGrocery']) : true;
if ($weekStart === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $weekStart)) {
sendJson(['success' => false, 'error' => 'weekStart must be YYYY-MM-DD'], 400);
}
if ($day < 0 || $day > 6) {
sendJson(['success' => false, 'error' => 'day must be 06 (0 = week start day)'], 400);
}
if (!in_array($mealType, mealSlotTypes(), true)) {
sendJson(['success' => false, 'error' => 'Invalid mealType'], 400);
}
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
if ($plan['weekStart'] !== $weekStart) {
sendJson(['success' => false, 'error' => 'That week is not the active plan. Set the week first (Head of household).'], 400);
}
$key = (string) $day;
if (!isset($plan['slots'][$key])) {
$plan['slots'][$key] = [
MEAL_SLOT_BREAKFAST => null,
MEAL_SLOT_LUNCH => null,
MEAL_SLOT_DINNER => null,
];
}
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
$pushed = 0;
if ($mealId !== null) {
$meal = findMealById($meals, $mealId);
if ($meal === null) {
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
}
$plan['slots'][$key][$mealType] = $mealId;
if ($pushGrocery && count($stores) > 0) {
$pushed = pushMealItemsToGrocery(normalizeMealRow($meal), $stores);
}
} else {
$plan['slots'][$key][$mealType] = null;
}
if (!writeJsonFile('meal_plans.json', $plan)) {
sendJson(['success' => false, 'error' => 'Failed to save meal plan'], 500);
}
sendJson(['success' => true, 'plan' => $plan, 'groceryLinesAdded' => $pushed]);

View File

@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can change the planning week'], 403);
}
$body = readJsonBody();
$weekStart = isset($body['weekStart']) ? trim((string) $body['weekStart']) : '';
if ($weekStart === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $weekStart)) {
sendJson(['success' => false, 'error' => 'weekStart must be YYYY-MM-DD (use the Monday of the week)'], 400);
}
$plan = [
'weekStart' => $weekStart,
'slots' => mealDefaultEmptySlots(),
];
if (!writeJsonFile('meal_plans.json', $plan)) {
sendJson(['success' => false, 'error' => 'Failed to save meal plan'], 500);
}
sendJson(['success' => true, 'plan' => $plan]);

34
api/meal_push_grocery.php Normal file
View File

@ -0,0 +1,34 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
requireActivePerson($people);
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
$meal = findMealById($meals, $id);
if ($meal === null) {
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
}
if (count($stores) === 0) {
sendJson(['success' => false, 'error' => 'Add a grocery store first'], 400);
}
$n = pushMealItemsToGrocery(normalizeMealRow($meal), $stores);
sendJson(['success' => true, 'groceryLinesAdded' => $n]);

69
api/meal_save.php Normal file
View File

@ -0,0 +1,69 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
$actorId = (string) ($actor['id'] ?? '');
$body = readJsonBody();
$meals = normalizeMealsList(readJsonFile('meals.json'));
$meals = migrateLegacyMealsList($meals);
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id !== '') {
$idx = findMealIndexById($meals, $id);
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
}
$existing = $meals[$idx];
$isAuthor = ($existing['author_id'] ?? '') === $actorId;
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
if (!$isAuthor && !$isHoH) {
sendJson(['success' => false, 'error' => 'You cannot edit this meal'], 403);
}
$row = $existing;
} else {
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can create meals'], 403);
}
$row = [
'id' => bin2hex(random_bytes(8)),
'author_id' => $actorId,
];
$idx = null;
}
$title = isset($body['title']) ? trim((string) $body['title']) : '';
if ($title === '') {
sendJson(['success' => false, 'error' => 'Title is required'], 400);
}
$row['title'] = $title;
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
$row['description'] = isset($body['description']) ? trim((string) $body['description']) : '';
$row['directions'] = isset($body['directions']) ? trim((string) $body['directions']) : '';
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
$row['tags'] = $body['tags'] ?? [];
$row['ingredients'] = $body['ingredients'] ?? [];
$row['items'] = $body['items'] ?? [];
$row = normalizeMealRow($row);
if ($idx === null) {
$meals[] = $row;
} else {
$meals[$idx] = $row;
}
if (!writeJsonFile('meals.json', $meals)) {
sendJson(['success' => false, 'error' => 'Failed to save meals'], 500);
}
sendJson(['success' => true, 'meal' => $row]);

71
api/people_create.php Normal file
View File

@ -0,0 +1,71 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
if (count($people) === 0) {
sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400);
}
assertHoHCanManagePeople($people);
$body = readJsonBody();
$name = isset($body['name']) ? trim((string) $body['name']) : '';
$role = isset($body['role']) ? (string) $body['role'] : '';
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
if ($name === '') {
sendJson(['success' => false, 'error' => 'Name is required'], 400);
}
$allowedRoles = [ROLE_HEAD, ROLE_ADULT, ROLE_CHILD];
if (!in_array($role, $allowedRoles, true)) {
sendJson(['success' => false, 'error' => 'Invalid role'], 400);
}
if ($role === ROLE_HEAD) {
if (strlen($pin) < 4) {
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters'], 400);
}
$pinHash = password_hash($pin, PASSWORD_DEFAULT);
} else {
$pinHash = null;
}
$icon = isset($body['icon']) ? trim((string) $body['icon']) : '';
$description = isset($body['description']) ? trim((string) $body['description']) : '';
$birthday = isset($body['birthday']) ? trim((string) $body['birthday']) : '';
$favoriteColor = isset($body['favoriteColor']) ? trim((string) $body['favoriteColor']) : '#4a90e2';
if ($favoriteColor !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $favoriteColor)) {
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
}
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
}
$newPerson = [
'id' => bin2hex(random_bytes(8)),
'name' => $name,
'role' => $role,
'pin_hash' => $pinHash,
'icon' => $icon,
'description' => $description,
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
'created_at' => gmdate('c'),
];
$people[] = $newPerson;
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
$safe = $newPerson;
unset($safe['pin_hash']);
sendJson(['success' => true, 'person' => $safe]);

View File

@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
if (count($people) > 0) {
sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403);
}
$body = readJsonBody();
$name = isset($body['name']) ? trim((string) $body['name']) : '';
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
if ($name === '') {
sendJson(['success' => false, 'error' => 'Name is required'], 400);
}
if (strlen($pin) < 4) {
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters'], 400);
}
$icon = isset($body['icon']) ? trim((string) $body['icon']) : '';
$description = isset($body['description']) ? trim((string) $body['description']) : '';
$birthday = isset($body['birthday']) ? trim((string) $body['birthday']) : '';
$favoriteColor = isset($body['favoriteColor']) ? trim((string) $body['favoriteColor']) : '#4a90e2';
if ($favoriteColor !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $favoriteColor)) {
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
}
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
}
$newPerson = [
'id' => bin2hex(random_bytes(8)),
'name' => $name,
'role' => ROLE_HEAD,
'pin_hash' => password_hash($pin, PASSWORD_DEFAULT),
'icon' => $icon,
'description' => $description,
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
'created_at' => gmdate('c'),
];
if (!writeJsonFile('people.json', [$newPerson])) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
setSessionPerson($newPerson['id'], true);
$safe = $newPerson;
unset($safe['pin_hash']);
sendJson(['success' => true, 'person' => $safe]);

50
api/people_delete.php Normal file
View File

@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
assertHoHCanManagePeople($people);
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$next = [];
$removed = null;
foreach ($people as $p) {
if (($p['id'] ?? '') === $id) {
$removed = $p;
continue;
}
$next[] = $p;
}
if ($removed === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$headCount = 0;
foreach ($next as $p) {
if (($p['role'] ?? '') === ROLE_HEAD) {
$headCount++;
}
}
if ($headCount < 1) {
sendJson(['success' => false, 'error' => 'Cannot remove the last Head of Household'], 400);
}
if (!writeJsonFile('people.json', $next)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
if (getActivePersonId() === $id) {
clearPersonaSession();
}
sendJson(['success' => true]);

48
api/people_reset_pin.php Normal file
View File

@ -0,0 +1,48 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
assertHoHCanManagePeople($people);
$body = readJsonBody();
$targetId = isset($body['targetPersonId']) ? trim((string) $body['targetPersonId']) : '';
$newPin = isset($body['newPin']) ? (string) $body['newPin'] : '';
if ($targetId === '') {
sendJson(['success' => false, 'error' => 'targetPersonId is required'], 400);
}
if (strlen($newPin) < 4) {
sendJson(['success' => false, 'error' => 'newPin must be at least 4 characters'], 400);
}
$target = findPersonById($people, $targetId);
if ($target === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
if (($target['role'] ?? '') !== ROLE_HEAD) {
sendJson(['success' => false, 'error' => 'PIN applies only to Head of Household profiles'], 400);
}
$updated = false;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $targetId) {
$people[$i]['pin_hash'] = password_hash($newPin, PASSWORD_DEFAULT);
$updated = true;
break;
}
}
if (!$updated) {
sendJson(['success' => false, 'error' => 'Update failed'], 500);
}
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
sendJson(['success' => true]);

96
api/people_update.php Normal file
View File

@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
assertHoHCanManagePeople($people);
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$idx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $id) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
if (isset($body['name'])) {
$name = trim((string) $body['name']);
if ($name === '') {
sendJson(['success' => false, 'error' => 'Name cannot be empty'], 400);
}
$people[$idx]['name'] = $name;
}
if (array_key_exists('icon', $body)) {
$people[$idx]['icon'] = trim((string) $body['icon']);
}
if (array_key_exists('description', $body)) {
$people[$idx]['description'] = trim((string) $body['description']);
}
if (array_key_exists('birthday', $body)) {
$birthday = trim((string) $body['birthday']);
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
}
$people[$idx]['birthday'] = $birthday;
}
if (array_key_exists('favoriteColor', $body)) {
$c = trim((string) $body['favoriteColor']);
if ($c !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $c)) {
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
}
if ($c !== '') {
$people[$idx]['favoriteColor'] = $c;
}
}
if (array_key_exists('role', $body)) {
$newRole = (string) $body['role'];
$allowedRoles = [ROLE_HEAD, ROLE_ADULT, ROLE_CHILD];
if (!in_array($newRole, $allowedRoles, true)) {
sendJson(['success' => false, 'error' => 'Invalid role'], 400);
}
$wasHead = ($people[$idx]['role'] ?? '') === ROLE_HEAD;
if ($newRole === ROLE_HEAD && !$wasHead) {
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
if (strlen($pin) < 4) {
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters when promoting to Head of Household'], 400);
}
$people[$idx]['pin_hash'] = password_hash($pin, PASSWORD_DEFAULT);
}
if ($newRole !== ROLE_HEAD && $wasHead) {
$people[$idx]['pin_hash'] = null;
}
$people[$idx]['role'] = $newRole;
}
$headCount = 0;
foreach ($people as $p) {
if (($p['role'] ?? '') === ROLE_HEAD) {
$headCount++;
}
}
if ($headCount < 1) {
sendJson(['success' => false, 'error' => 'At least one Head of Household is required'], 400);
}
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
$safe = $people[$idx];
unset($safe['pin_hash']);
sendJson(['success' => true, 'person' => $safe]);

39
api/store_create.php Normal file
View File

@ -0,0 +1,39 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
}
$body = readJsonBody();
$name = isset($body['name']) ? trim((string) $body['name']) : '';
if ($name === '') {
sendJson(['success' => false, 'error' => 'Name is required'], 400);
}
$stores = normalizeStoresList(readJsonFile('stores.json'));
$newId = bin2hex(random_bytes(8));
$sort = count($stores);
$stores[] = ['id' => $newId, 'name' => $name, 'sort' => $sort];
if (!writeJsonFile('stores.json', $stores)) {
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$newId])) {
$lists['byStore'][$newId] = [];
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
sendJson(['success' => false, 'error' => 'Failed to init store list'], 500);
}
sendJson(['success' => true, 'store' => ['id' => $newId, 'name' => $name, 'sort' => $sort]]);

61
api/store_delete.php Normal file
View File

@ -0,0 +1,61 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
}
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
if ($id === '') {
sendJson(['success' => false, 'error' => 'id is required'], 400);
}
$stores = normalizeStoresList(readJsonFile('stores.json'));
if (count($stores) <= 1) {
sendJson(['success' => false, 'error' => 'You must keep at least one store'], 400);
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (groceryStoreHasItems($lists, $id)) {
sendJson(['success' => false, 'error' => 'Remove or purchase all items in this store before deleting it'], 400);
}
$next = [];
foreach ($stores as $s) {
if (($s['id'] ?? '') === $id) {
continue;
}
$next[] = $s;
}
unset($lists['byStore'][$id]);
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$catalogNext = [];
foreach ($catalog as $row) {
if ((string) ($row['storeId'] ?? '') === $id) {
continue;
}
$catalogNext[] = $row;
}
if (!writeJsonFile('stores.json', $next)) {
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
sendJson(['success' => false, 'error' => 'Failed to save grocery lists'], 500);
}
if (!writeJsonFile('grocery_catalog.json', $catalogNext)) {
sendJson(['success' => false, 'error' => 'Failed to save catalog'], 500);
}
sendJson(['success' => true]);

43
api/store_update.php Normal file
View File

@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
}
$body = readJsonBody();
$id = isset($body['id']) ? trim((string) $body['id']) : '';
$name = isset($body['name']) ? trim((string) $body['name']) : '';
if ($id === '' || $name === '') {
sendJson(['success' => false, 'error' => 'id and name are required'], 400);
}
$stores = normalizeStoresList(readJsonFile('stores.json'));
$found = false;
foreach ($stores as $i => $s) {
if (($s['id'] ?? '') === $id) {
$stores[$i]['name'] = $name;
if (isset($body['sort']) && is_numeric($body['sort'])) {
$stores[$i]['sort'] = (int) $body['sort'];
}
$found = true;
break;
}
}
if (!$found) {
sendJson(['success' => false, 'error' => 'Store not found'], 404);
}
if (!writeJsonFile('stores.json', $stores)) {
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
}
sendJson(['success' => true]);

35
api/switch_person.php Normal file
View File

@ -0,0 +1,35 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$body = readJsonBody();
$personId = isset($body['personId']) ? trim((string) $body['personId']) : '';
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
if ($personId === '') {
sendJson(['success' => false, 'error' => 'personId is required'], 400);
}
$people = normalizePeopleList(readJsonFile('people.json'));
$person = findPersonById($people, $personId);
if ($person === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$role = $person['role'] ?? '';
$pinHash = $person['pin_hash'] ?? null;
if ($role === ROLE_HEAD && is_string($pinHash) && $pinHash !== '') {
if ($pin === '' || !password_verify($pin, $pinHash)) {
sendJson(['success' => false, 'error' => 'PIN required or incorrect'], 403);
}
setSessionPerson($personId, true);
} else {
setSessionPerson($personId, false);
}
sendJson(['success' => true]);

View File

@ -14,6 +14,51 @@ body {
color: var(--text-color);
}
.app-header {
background: linear-gradient(135deg, var(--person-accent, #4a90e2) 0%, #2c5282 100%);
}
.persona-chip {
min-height: 2.75rem;
touch-action: manipulation;
}
.persona-chip.active {
font-weight: 600;
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.35);
}
.family-hub-body .card {
border-color: var(--border-color);
}
.chore-thumb {
max-height: 180px;
object-fit: cover;
}
.meal-hero {
max-height: 320px;
width: 100%;
object-fit: cover;
}
.chore-card .card-body {
display: flex;
flex-direction: column;
}
.grocery-store-nav .nav-link {
text-align: left;
touch-action: manipulation;
}
.grocery-line-thumb {
width: 56px;
height: 56px;
object-fit: cover;
}
.container {
max-width: 1200px;
margin: 0 auto;

View File

@ -1,25 +1,795 @@
document.addEventListener('DOMContentLoaded', function() {
// Tab functionality
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
/**
* Tabs use full page navigation (?tab=) from index.php; active state is set server-side.
* Do not programmatically .click() <a class="tab"> links on load that re-follows href and reloads in a tight loop.
*/
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.style.display = 'none');
(function ($) {
'use strict';
// Add active class to clicked tab
tab.classList.add('active');
// Show corresponding content
const targetId = tab.getAttribute('data-target');
document.getElementById(targetId).style.display = 'block';
var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api';
function postJson(path, body) {
return fetch(apiBase + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body || {})
}).then(function (res) {
return res.json().then(function (data) {
if (!res.ok) {
var err = new Error(data.error || res.statusText || 'Request failed');
err.payload = data;
err.status = res.status;
throw err;
}
return data;
});
});
}
function showAlert($el, kind, message) {
if (!$el || !$el.length) {
return;
}
$el.removeClass('d-none alert-success alert-danger alert-warning');
$el.addClass('alert-' + kind);
$el.text(message);
}
var pendingPersonId = null;
var pinModal = null;
function switchPerson(personId, pin) {
return postJson('/switch_person.php', { personId: personId, pin: pin || '' });
}
function bindPersonaSwitcher() {
$(document).on('click', '.persona-chip', function () {
var $btn = $(this);
var id = $btn.data('person-id');
var needsPin = String($btn.data('needs-pin')) === '1';
var name = $btn.data('person-name') || 'this profile';
if (!id) {
return;
}
if (needsPin) {
pendingPersonId = id;
$('#hohPinModalPrompt').text('Enter PIN for ' + name + '.');
$('#hohPinInput').val('');
$('#hohPinError').addClass('d-none').text('');
if (!pinModal && typeof bootstrap !== 'undefined') {
pinModal = new bootstrap.Modal(document.getElementById('hohPinModal'));
}
if (pinModal) {
pinModal.show();
}
setTimeout(function () {
document.getElementById('hohPinInput').focus();
}, 200);
return;
}
switchPerson(id, '')
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not switch profile');
});
});
// Initialize first tab as active
if (tabs.length > 0) {
tabs[0].click();
$('#hohPinSubmit').on('click', function () {
var pin = $('#hohPinInput').val() || '';
if (!pendingPersonId) {
return;
}
$('#hohPinError').addClass('d-none').text('');
switchPerson(pendingPersonId, pin)
.then(function () {
if (pinModal) {
pinModal.hide();
}
pendingPersonId = null;
window.location.reload();
})
.catch(function (err) {
$('#hohPinError').removeClass('d-none').text(err.message || 'PIN incorrect');
});
});
}
function bindFirstHeadForm() {
var $form = $('#firstHeadForm');
if (!$form.length) {
return;
}
});
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
name: $('#first_name').val(),
pin: $('#first_pin').val(),
favoriteColor: $('#first_favoriteColor').val()
};
postJson('/people_create_first.php', payload)
.then(function () {
showAlert($('#firstHeadFeedback'), 'success', 'Profile created. Reloading…');
window.location.reload();
})
.catch(function (err) {
showAlert($('#firstHeadFeedback'), 'danger', err.message || 'Could not create profile');
$('#firstHeadFeedback').removeClass('d-none');
});
});
}
function toggleAddPinVisibility() {
var role = $('#add_role').val();
var $wrap = $('#add_pin_wrap');
var $pin = $('#add_pin');
if (role === 'head_of_household') {
$wrap.show();
$pin.prop('required', true);
} else {
$wrap.hide();
$pin.prop('required', false);
$pin.val('');
}
}
function bindAddPersonForm() {
var $form = $('#addPersonForm');
if (!$form.length) {
return;
}
$('#add_role').on('change', toggleAddPinVisibility);
toggleAddPinVisibility();
$form.on('submit', function (e) {
e.preventDefault();
var role = $('#add_role').val();
var payload = {
name: $('#add_name').val(),
role: role,
pin: role === 'head_of_household' ? $('#add_pin').val() : '',
favoriteColor: $('#add_favoriteColor').val()
};
postJson('/people_create.php', payload)
.then(function () {
showAlert($('#addPersonFeedback'), 'success', 'Person added. Reloading…');
$('#addPersonFeedback').removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
showAlert($('#addPersonFeedback'), 'danger', err.message || 'Could not add person');
$('#addPersonFeedback').removeClass('d-none');
});
});
}
function bindFamilySettingsForm() {
var $form = $('#familySettingsForm');
if (!$form.length) {
return;
}
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
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)
.then(function () {
showAlert($('#familySettingsFeedback'), 'success', 'Saved.');
$('#familySettingsFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not save');
$('#familySettingsFeedback').removeClass('d-none');
});
});
}
function choreListPayloadFromForm() {
var type = $('#chore_list_type').val() || 'checkbox';
var raw = $('#chore_list_items').val() || '';
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
if (lines.length === 0) {
return [];
}
return [{ type: type, items: lines }];
}
function toggleChoreRecurrence() {
var sched = $('#chore_schedule').val();
if (sched === 'recurring') {
$('#chore_recurrence_wrap').show();
} else {
$('#chore_recurrence_wrap').hide();
}
}
function bindChoresPage() {
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,
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');
});
});
$(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.')) {
return;
}
postJson('/chore_submit.php', { id: id, note: '' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not submit');
});
});
$(document).on('click', '.btn-chore-approve', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Approve and pay the reward?')) {
return;
}
postJson('/chore_review.php', { id: id, decision: 'approve' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not approve');
});
});
$(document).on('click', '.btn-chore-reject', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Reject this submission?')) {
return;
}
postJson('/chore_review.php', { id: id, decision: 'reject' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not reject');
});
});
}
function groceryReloadKeepParams() {
var $tab = $('#groceries');
var store = $tab.data('store-id') || $('#grocery_store_id').val();
var params = new URLSearchParams(window.location.search);
var filter = params.get('filter') || 'active';
var q = '?tab=groceries&store=' + encodeURIComponent(store || '') + '&filter=' + encodeURIComponent(filter);
window.location.href = window.location.pathname + q;
}
function bindStoreManagement() {
$('#storeAddForm').on('submit', function (e) {
e.preventDefault();
var name = ($('#new_store_name').val() || '').trim();
if (!name) {
return;
}
postJson('/store_create.php', { name: name })
.then(function () {
window.location.reload();
})
.catch(function (err) {
var $fb = $('#storeAddFeedback');
showAlert($fb, 'danger', err.message || 'Could not add store');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-store-delete', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Delete this store? Lists must be empty.')) {
return;
}
postJson('/store_delete.php', { id: id })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not delete');
});
});
$(document).on('click', '.btn-store-rename', function () {
var id = $(this).data('id');
var current = $(this).data('name') || '';
var name = window.prompt('Store name', current);
if (name === null) {
return;
}
name = String(name).trim();
if (!name) {
return;
}
postJson('/store_update.php', { id: id, name: name })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not rename');
});
});
}
function bindGroceryPage() {
var $tab = $('#groceries');
if (!$tab.length) {
return;
}
$('#grocery_catalog_pick').on('change', function () {
var v = $(this).val();
if (!v) {
return;
}
var $o = $(this).find('option:selected');
$('#grocery_name').val($o.data('name') || '');
$('#grocery_description').val($o.data('description') || '');
$('#grocery_size').val($o.data('size') || '');
$('#grocery_image').val($o.data('image') || '');
});
$('#groceryAddForm').on('submit', function (e) {
e.preventDefault();
var storeId = ($('#grocery_store_id').val() || $tab.data('store-id') || '').trim();
var payload = {
storeId: storeId,
catalogPickId: $('#grocery_catalog_pick').val() || '',
name: $('#grocery_name').val(),
description: $('#grocery_description').val(),
size: $('#grocery_size').val(),
quantity: $('#grocery_quantity').val(),
price: $('#grocery_price').val(),
image: $('#grocery_image').val(),
recurringIntervalDays: parseInt($('#grocery_recurring').val(), 10) || 0,
source: 'manual'
};
postJson('/grocery_item_create.php', payload)
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
var $fb = $('#groceryFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not add item');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-grocery-purchase', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
var purchased = String($(this).data('purchased')) === '1';
postJson('/grocery_item_purchase.php', {
storeId: storeId,
itemId: itemId,
purchased: purchased
})
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not update');
});
});
$(document).on('click', '.btn-grocery-delete', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
if (!itemId || !window.confirm('Remove this item?')) {
return;
}
postJson('/grocery_item_delete.php', { storeId: storeId, itemId: itemId })
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not remove');
});
});
$(document).on('click', '.btn-grocery-approve', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
if (!itemId) {
return;
}
postJson('/grocery_item_review.php', { storeId: storeId, itemId: itemId })
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not approve');
});
});
}
function mealListPayloadFromForm() {
var type = $('#meal_list_type').val() || 'checkbox';
var raw = $('#meal_list_items').val() || '';
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
if (lines.length === 0) {
return [];
}
return [{ type: type, items: lines }];
}
function mealGroceryRowsPayload() {
var out = [];
$('#mealGroceryRows .meal-grocery-row').each(function () {
var $r = $(this);
var name = ($r.find('.g-name').val() || '').trim();
if (!name) {
return;
}
out.push({
name: name,
storeId: ($r.find('.g-store').val() || '').trim(),
quantity: ($r.find('.g-qty').val() || '1').trim() || '1',
size: ($r.find('.g-size').val() || '').trim(),
description: '',
price: '',
image: ''
});
});
return out;
}
function readMealsLibrary() {
var el = document.getElementById('mealsLibraryJson');
if (!el || !el.textContent) {
return [];
}
try {
return JSON.parse(el.textContent);
} catch (e) {
return [];
}
}
function hideMealSlotModal() {
var el = document.getElementById('mealSlotModal');
if (el && typeof bootstrap !== 'undefined') {
var inst = bootstrap.Modal.getInstance(el);
if (inst) {
inst.hide();
}
}
}
function bindMealsPage() {
var $tab = $('#meals');
if (!$tab.length) {
return;
}
var weekStart = ($tab.attr('data-week-start') || '').trim();
$('#btnPushMealGrocery').on('click', function () {
var id = $(this).data('meal-id');
if (!id) {
return;
}
postJson('/meal_push_grocery.php', { id: id })
.then(function (data) {
var n = data.groceryLinesAdded != null ? data.groceryLinesAdded : 0;
showAlert($('#pushMealFeedback'), 'success', 'Added ' + n + ' line(s) to grocery (pending review).');
$('#pushMealFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#pushMealFeedback'), 'danger', err.message || 'Could not push');
$('#pushMealFeedback').removeClass('d-none');
});
});
$(document).on('click', '.btn-ingredient-to-grocery', function () {
var $btn = $(this);
var mealId = $btn.data('meal-id');
var ing = $btn.data('ingredient');
var $li = $btn.closest('li');
var storeId = $li.find('.ingredient-store').val() || '';
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
.then(function () {
window.alert('Added to grocery list.');
})
.catch(function (err) {
window.alert(err.message || 'Could not add');
});
});
$('#btnMealWeekApply').on('click', function () {
var ws = ($('#meal_week_start').val() || '').trim();
if (!ws) {
return;
}
if (!window.confirm('Change the planning week? This clears the grid for the new week.')) {
return;
}
postJson('/meal_plan_set_week.php', { weekStart: ws })
.then(function () {
window.location.reload();
})
.catch(function (err) {
showAlert($('#mealWeekFeedback'), 'danger', err.message || 'Could not set week');
$('#mealWeekFeedback').removeClass('d-none');
});
});
var slotModalEl = document.getElementById('mealSlotModal');
var slotModal = null;
if (slotModalEl && typeof bootstrap !== 'undefined') {
slotModal = new bootstrap.Modal(slotModalEl);
}
function renderSlotPickerList() {
var lib = readMealsLibrary();
var filter = ($('#slotPickerFilter').val() || '').toLowerCase();
var $ul = $('#slotPickerList');
$ul.empty();
lib.forEach(function (m) {
if (filter) {
var tags = (m.tags || []).map(function (t) { return String(t).toLowerCase(); });
if (tags.indexOf(filter) === -1) {
return;
}
}
var $li = $('<li class="list-group-item list-group-item-action" role="button"></li>')
.text(m.title || m.id)
.attr('data-meal-id', m.id);
$ul.append($li);
});
if ($ul.children().length === 0) {
$ul.append('<li class="list-group-item text-muted">No meals match this filter.</li>');
}
}
$(document).on('click', '.btn-open-slot-picker', function () {
var day = $(this).data('day');
var mealType = $(this).data('meal-type');
$('#slotPickerDay').val(String(day));
$('#slotPickerType').val(String(mealType));
if (mealType) {
$('#slotPickerFilter').val(String(mealType));
}
renderSlotPickerList();
if (slotModal) {
slotModal.show();
}
});
$('#slotPickerFilter').on('change', renderSlotPickerList);
$(document).on('click', '#slotPickerList li[data-meal-id]', function () {
var mealId = $(this).attr('data-meal-id');
if (!mealId || !weekStart) {
return;
}
var day = parseInt($('#slotPickerDay').val(), 10);
var mealType = $('#slotPickerType').val();
var pushGrocery = $('#slotPickerPushGrocery').is(':checked');
postJson('/meal_plan_set_slot.php', {
weekStart: weekStart,
day: day,
mealType: mealType,
mealId: String(mealId),
pushGrocery: pushGrocery
})
.then(function () {
hideMealSlotModal();
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not assign meal');
});
});
$('#slotPickerClear').on('click', function () {
if (!weekStart) {
return;
}
var day = parseInt($('#slotPickerDay').val(), 10);
var mealType = $('#slotPickerType').val();
postJson('/meal_plan_set_slot.php', {
weekStart: weekStart,
day: day,
mealType: mealType,
mealId: '',
pushGrocery: false
})
.then(function () {
hideMealSlotModal();
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not clear slot');
});
});
$('#mealSaveForm').on('submit', function (e) {
e.preventDefault();
var tags = [];
$('.meal-tag-cb:checked').each(function () {
tags.push($(this).val());
});
var ingRaw = $('#meal_ingredients').val() || '';
var ingredients = ingRaw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
var payload = {
id: ($('#meal_id').val() || '').trim(),
title: ($('#meal_title').val() || '').trim(),
image: $('#meal_image').val(),
description: $('#meal_description').val(),
directions: $('#meal_directions').val(),
lists: mealListPayloadFromForm(),
tags: tags,
ingredients: ingredients,
items: mealGroceryRowsPayload()
};
postJson('/meal_save.php', payload)
.then(function () {
window.location.href = window.location.pathname + '?tab=meals';
})
.catch(function (err) {
showAlert($('#mealSaveFeedback'), 'danger', err.message || 'Could not save');
$('#mealSaveFeedback').removeClass('d-none');
});
});
$('#btnAddGroceryRow').on('click', function () {
var $first = $('#mealGroceryRows .meal-grocery-row').first();
if (!$first.length) {
return;
}
var $clone = $first.clone();
$clone.find('input').val('');
$clone.find('.g-qty').val('1');
$clone.find('select').prop('selectedIndex', 0);
$('#mealGroceryRows').append($clone);
});
$(document).on('click', '.btn-meal-delete', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Delete this meal from the library?')) {
return;
}
postJson('/meal_delete.php', { id: id })
.then(function () {
window.location.href = window.location.pathname + '?tab=meals';
})
.catch(function (err) {
window.alert(err.message || 'Could not delete');
});
});
}
function bindCurrencyPage() {
var $form = $('#expenseForm');
if (!$form.length) {
return;
}
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
title: $('#expense_title').val(),
description: $('#expense_description').val(),
date: $('#expense_date').val(),
value: Number($('#expense_value').val()),
assignee_id: $('#expense_assignee').val()
};
postJson('/expense_create.php', payload)
.then(function () {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'success', 'Expense recorded. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not record expense');
$fb.removeClass('d-none');
});
});
}
function bindPeopleTable() {
$(document).on('click', '.btn-delete-person', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Remove this person from the hub?')) {
return;
}
postJson('/people_delete.php', { id: id })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not remove');
});
});
$(document).on('click', '.btn-reset-pin', function () {
var id = $(this).data('id');
var pin = window.prompt('Enter a new PIN (4+ characters) for this Head of household.') || '';
if (pin.length < 4) {
return;
}
postJson('/people_reset_pin.php', { targetPersonId: id, newPin: pin })
.then(function () {
window.alert('PIN updated.');
})
.catch(function (err) {
window.alert(err.message || 'Could not reset PIN');
});
});
}
$(function () {
bindPersonaSwitcher();
bindFirstHeadForm();
bindAddPersonForm();
bindFamilySettingsForm();
bindPeopleTable();
bindChoresPage();
bindCurrencyPage();
bindStoreManagement();
bindGroceryPage();
bindMealsPage();
});
})(jQuery);

View File

@ -1,6 +1,17 @@
<?php
require_once __DIR__ . '/env.php';
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
if (PHP_SAPI === 'cli') {
fwrite(STDERR, "Family Hub: .env file not found.\n");
fwrite(STDERR, "Open install.php in your browser or copy env.example to .env and edit.\n");
exit(1);
}
header('Location: install.php');
exit;
}
// Load environment variables
Env::load();
@ -31,7 +42,10 @@ define('EXPORT_RETENTION_DAYS', (int)Env::get('EXPORT_RETENTION_DAYS', 30));
$TABS = [
'chores' => ['title' => 'Chores', 'icon' => 'tasks'],
'groceries' => ['title' => 'Grocery List', 'icon' => 'shopping-cart'],
'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils']
'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils'],
'calendar' => ['title' => 'Calendar', 'icon' => 'calendar-days'],
'currency' => ['title' => 'Currency', 'icon' => 'coins'],
'settings' => ['title' => 'Family settings', 'icon' => 'cog'],
];
// Load local configuration if exists

View File

@ -7,7 +7,7 @@ class Env {
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
throw new Exception('.env file not found. Please create one based on .env.example');
throw new Exception('.env file not found. Please create one based on env.example or run install.php');
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

View File

@ -15,7 +15,10 @@ function exportData($type) {
// Handle export request
if (isset($_GET['type'])) {
$type = sanitizeInput($_GET['type']);
if (in_array($type, ['chores', 'groceries', 'meals'])) {
if (in_array($type, [
'chores', 'groceries', 'meals', 'meal_plans', 'expenses',
'stores', 'grocery_lists', 'grocery_catalog',
], true)) {
if (exportData($type)) {
echo json_encode(['success' => true, 'message' => 'Export successful']);
} else {

View File

@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/utils.php';
require_once __DIR__ . '/persona.php';
header('Content-Type: application/json; charset=utf-8');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
/**
* @return array<string, mixed>
*/
function readJsonBody(): array {
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function sendJson(array $payload, int $code = 200): void {
http_response_code($code);
echo json_encode($payload);
exit;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<string, mixed>
*/
function requireActivePerson(array $people): array {
$p = getActivePerson($people);
if ($p === null) {
sendJson(['success' => false, 'error' => 'Select who is using the hub first.'], 403);
}
return $p;
}

198
includes/chore_helpers.php Normal file
View File

@ -0,0 +1,198 @@
<?php
require_once __DIR__ . '/persona.php';
const CHORE_SCHEDULE_ONCE = 'once';
const CHORE_SCHEDULE_RECURRING = 'recurring';
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeChoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<int, string>
*/
function legacyAssigneeNameToIds(string $name, array $people): array {
$name = trim($name);
if ($name === '') {
return [];
}
$lower = mb_strtolower($name, 'UTF-8');
$ids = [];
foreach ($people as $p) {
$pn = trim((string) ($p['name'] ?? ''));
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) {
$ids[] = (string) $p['id'];
}
}
return $ids;
}
/**
* @param array<string, mixed> $c
* @param array<int, array<string, mixed>> $people
* @return array<string, mixed>
*/
function migrateLegacyChoreRow(array $c, array $people): array {
if (isset($c['name']) && !isset($c['title'])) {
$c['title'] = (string) $c['name'];
}
if (!isset($c['assignee_ids']) || !is_array($c['assignee_ids'])) {
if (isset($c['assignee'])) {
$c['assignee_ids'] = legacyAssigneeNameToIds((string) $c['assignee'], $people);
} else {
$c['assignee_ids'] = [];
}
}
$ids = [];
foreach ($c['assignee_ids'] as $id) {
if (is_string($id) && $id !== '') {
$ids[] = $id;
}
}
$c['assignee_ids'] = array_values(array_unique($ids));
if (!isset($c['lists']) || !is_array($c['lists'])) {
$c['lists'] = [];
}
$c['lists'] = normalizeChoreLists($c['lists']);
if (!isset($c['description'])) {
$c['description'] = '';
}
if (!isset($c['image'])) {
$c['image'] = '';
}
if (!isset($c['author_id'])) {
$c['author_id'] = '';
}
if (!isset($c['value'])) {
$c['value'] = 0;
}
$c['value'] = is_numeric($c['value']) ? (float) $c['value'] : 0.0;
if (!isset($c['due_date'])) {
$c['due_date'] = '';
}
$c['due_date'] = is_string($c['due_date']) ? trim($c['due_date']) : '';
if ($c['due_date'] !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $c['due_date'])) {
$c['due_date'] = '';
}
$sched = $c['schedule'] ?? CHORE_SCHEDULE_ONCE;
$c['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
if (!isset($c['recurrence_days']) || !is_numeric($c['recurrence_days'])) {
$c['recurrence_days'] = 7;
}
$c['recurrence_days'] = max(1, (int) $c['recurrence_days']);
if (!isset($c['status'])) {
$c['status'] = 'active';
}
$st = (string) $c['status'];
$c['status'] = in_array($st, ['active', 'completed'], true) ? $st : 'active';
if (!array_key_exists('pending_submission', $c)) {
$c['pending_submission'] = null;
}
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
$c['pending_submission'] = null;
}
return $c;
}
/**
* @param mixed $lists
* @return array<int, array{type: string, items: array<int, string>}>
*/
function normalizeChoreLists($lists): array {
if (!is_array($lists)) {
return [];
}
$out = [];
foreach ($lists as $block) {
if (!is_array($block)) {
continue;
}
$type = isset($block['type']) ? (string) $block['type'] : 'checkbox';
if (!in_array($type, ['ordered', 'unordered', 'checkbox'], true)) {
$type = 'checkbox';
}
$items = $block['items'] ?? [];
if (!is_array($items)) {
$items = [];
}
$clean = [];
foreach ($items as $it) {
$s = trim((string) $it);
if ($s !== '') {
$clean[] = $s;
}
}
$out[] = ['type' => $type, 'items' => $clean];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
* @return array<int, array<string, mixed>>
*/
function migrateAllChores(array $chores, array $people): array {
$out = [];
foreach ($chores as $c) {
$out[] = migrateLegacyChoreRow($c, $people);
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreById(array $chores, string $id): ?array {
foreach ($chores as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreIndexById(array $chores, string $id): ?int {
foreach ($chores as $i => $c) {
if (($c['id'] ?? '') === $id) {
return $i;
}
}
return null;
}
/**
* @param array<int, string> $assigneeIds
* @param array<int, array<string, mixed>> $people
*/
function choreAssigneeIdsValid(array $assigneeIds, array $people): bool {
$set = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$set[(string) $p['id']] = true;
}
}
foreach ($assigneeIds as $id) {
if (!isset($set[$id])) {
return false;
}
}
return true;
}

View File

@ -1,23 +1,65 @@
<?php
function readJsonFile($filename) {
$filepath = __DIR__ . '/../data/' . $filename;
function getDataDirectory(): string {
return __DIR__ . '/../data';
}
function getDataFilePath(string $filename): string {
return getDataDirectory() . '/' . $filename;
}
function ensureDataDirectory(): void {
$dataDir = getDataDirectory();
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
}
function readJsonFile(string $filename) {
$filepath = getDataFilePath($filename);
if (!file_exists($filepath)) {
return [];
}
$content = file_get_contents($filepath);
return json_decode($content, true) ?? [];
}
function writeJsonFile($filename, $data) {
$filepath = __DIR__ . '/../data/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT);
return file_put_contents($filepath, $json);
}
function ensureDataDirectory() {
$dataDir = __DIR__ . '/../data';
if (!file_exists($dataDir)) {
mkdir($dataDir, 0755, true);
$fp = fopen($filepath, 'rb');
if ($fp === false) {
return [];
}
}
if (!flock($fp, LOCK_SH)) {
fclose($fp);
return [];
}
$content = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
if ($content === false || $content === '') {
return [];
}
$decoded = json_decode($content, true);
return $decoded ?? [];
}
function writeJsonFile(string $filename, $data): bool {
ensureDataDirectory();
$filepath = getDataFilePath($filename);
$fp = fopen($filepath, 'c+');
if ($fp === false) {
return false;
}
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return false;
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) {
flock($fp, LOCK_UN);
fclose($fp);
return false;
}
ftruncate($fp, 0);
rewind($fp);
$written = fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $written !== false;
}

View File

@ -0,0 +1,18 @@
<?php
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeExpensesList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}

View File

@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/db.php';
function defaultFamilySettings(): array {
return [
'currency_symbol' => '★',
'currency_name' => 'Stars',
'currency_permanence' => 'permanent',
'timezone' => 'UTC',
'week_starts_on' => 0,
];
}
function loadFamilySettings(): array {
$raw = readJsonFile('family_settings.json');
if (!is_array($raw)) {
return defaultFamilySettings();
}
return array_merge(defaultFamilySettings(), $raw);
}
/**
* Tab label: symbol + name (e.g. "★ Stars").
*/
function currencyTabLabel(array $familySettings): string {
$sym = trim((string) ($familySettings['currency_symbol'] ?? ''));
$name = trim((string) ($familySettings['currency_name'] ?? ''));
$label = trim($sym . ' ' . $name);
return $label !== '' ? $label : 'Currency';
}

View File

@ -0,0 +1,395 @@
<?php
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeStoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeCatalogList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
$n = mb_strtolower(trim($name), 'UTF-8');
$s = mb_strtolower(trim($size), 'UTF-8');
return $storeId . '|' . $n . '|' . $s;
}
/**
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function defaultGroceryListsShape(): array {
return ['byStore' => []];
}
/**
* @param mixed $raw
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function normalizeGroceryLists($raw): array {
$base = defaultGroceryListsShape();
if (!is_array($raw)) {
return $base;
}
if (isset($raw['byStore']) && is_array($raw['byStore'])) {
$base['byStore'] = [];
foreach ($raw['byStore'] as $storeId => $items) {
$sid = (string) $storeId;
if ($sid === '') {
continue;
}
if (!is_array($items) || !array_is_list($items)) {
$base['byStore'][$sid] = [];
continue;
}
$clean = [];
foreach ($items as $it) {
if (is_array($it) && !empty($it['id']) && is_string($it['id'])) {
$clean[] = $it;
}
}
$base['byStore'][$sid] = $clean;
}
return $base;
}
return $base;
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function findStoreById(array $stores, string $id): ?array {
foreach ($stores as $s) {
if (($s['id'] ?? '') === $id) {
return $s;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $catalog
*/
function findCatalogById(array $catalog, string $id): ?array {
foreach ($catalog as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* Deduped rows for picker: one per dedupeKey (first wins for label).
*
* @param array<int, array<string, mixed>> $catalog
* @return array<int, array<string, mixed>>
*/
function groceryCatalogPickerOptions(array $catalog, ?string $storeId = null): array {
$seen = [];
$out = [];
foreach ($catalog as $row) {
if ($storeId !== null && (string) ($row['storeId'] ?? '') !== $storeId) {
continue;
}
$key = (string) ($row['dedupeKey'] ?? '');
if ($key === '') {
$key = groceryCatalogDedupeKey(
(string) ($row['storeId'] ?? ''),
(string) ($row['name'] ?? ''),
(string) ($row['defaultSize'] ?? '')
);
}
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort($out, static function ($a, $b) {
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return $out;
}
/**
* Migrate legacy groceries.json flat list into stores + lists + empty catalog.
*
* @return array{stores: array<int, array<string, mixed>>, lists: array{byStore: array<string, array<int, array<string, mixed>>>}, migrated: bool}
*/
function migrateLegacyGroceriesIfNeeded(): array {
$listsPath = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$hasNew = count($listsPath['byStore']) > 0;
$legacy = readJsonFile('groceries.json');
$legacyList = is_array($legacy) && array_is_list($legacy) ? $legacy : [];
if ($hasNew || count($legacyList) === 0) {
return ['stores' => normalizeStoresList(readJsonFile('stores.json')), 'lists' => $listsPath, 'migrated' => false];
}
$storeId = bin2hex(random_bytes(8));
$stores = [[
'id' => $storeId,
'name' => 'General',
'sort' => 0,
]];
$items = [];
foreach ($legacyList as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$cat = trim((string) ($row['category'] ?? ''));
$desc = trim((string) ($row['description'] ?? ''));
if ($cat !== '' && $desc !== '') {
$desc = 'Category: ' . $cat . "\n" . $desc;
} elseif ($cat !== '') {
$desc = 'Category: ' . $cat;
}
$items[] = [
'id' => bin2hex(random_bytes(8)),
'catalogId' => null,
'name' => $name,
'description' => $desc,
'size' => '',
'quantity' => (string) ($row['quantity'] ?? '1'),
'price' => '',
'image' => '',
'status' => 'active',
'purchasedAt' => null,
'source' => 'manual',
'recurringIntervalDays' => 0,
'addedAt' => gmdate('c'),
];
}
$listsPath['byStore'][$storeId] = $items;
writeJsonFile('stores.json', $stores);
writeJsonFile('grocery_lists.json', $listsPath);
return ['stores' => $stores, 'lists' => $listsPath, 'migrated' => true];
}
/**
* Create catalog row + list line (used by grocery_item_create and meal grocery).
*
* @param array<int, array<string, mixed>> $stores
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
*/
function groceryAppendShoppingLine(
array $stores,
string $storeId,
string $name,
string $description = '',
string $size = '',
string $quantity = '1',
string $price = '',
string $image = '',
string $source = 'manual',
int $recurringIntervalDays = 0,
?string $mealId = null,
?string $mealTitleMeta = null
): array {
if (findStoreById($stores, $storeId) === null) {
return ['ok' => false, 'error' => 'Invalid store'];
}
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'error' => 'Name is required'];
}
$description = trim($description);
$size = trim($size);
$quantity = trim($quantity) !== '' ? trim($quantity) : '1';
$price = trim($price);
$image = trim($image);
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
$catalogId = null;
foreach ($catalog as $i => $c) {
if ((string) ($c['storeId'] ?? '') === $storeId && (string) ($c['dedupeKey'] ?? '') === $dedupeKey) {
$catalogId = (string) $c['id'];
$catalog[$i]['name'] = $name;
$catalog[$i]['description'] = $description;
$catalog[$i]['defaultSize'] = $size;
$catalog[$i]['defaultImage'] = $image;
$catalog[$i]['dedupeKey'] = $dedupeKey;
break;
}
}
if ($catalogId === null) {
$catalogId = bin2hex(random_bytes(8));
$catalog[] = [
'id' => $catalogId,
'storeId' => $storeId,
'dedupeKey' => $dedupeKey,
'name' => $name,
'description' => $description,
'defaultSize' => $size,
'defaultImage' => $image,
'lastPurchaseAt' => null,
'recurringIntervalDays' => 0,
'nextDueDate' => null,
];
}
$status = 'active';
if ($source === 'meal_plan' || $source === 'pending_review') {
$status = 'pending_review';
}
$src = $source === 'pending_review' ? 'meal_plan' : $source;
if (!in_array($src, ['manual', 'meal_plan'], true)) {
$src = 'manual';
}
$line = normalizeGroceryLineItem([
'id' => bin2hex(random_bytes(8)),
'catalogId' => $catalogId,
'name' => $name,
'description' => $description,
'size' => $size,
'quantity' => $quantity,
'price' => $price,
'image' => $image,
'status' => $status,
'purchasedAt' => null,
'source' => $src,
'recurringIntervalDays' => max(0, $recurringIntervalDays),
'addedAt' => gmdate('c'),
]);
if ($mealId !== null && $mealId !== '') {
$line['mealId'] = $mealId;
}
if ($mealTitleMeta !== null && $mealTitleMeta !== '') {
$line['mealTitle'] = $mealTitleMeta;
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$storeId])) {
$lists['byStore'][$storeId] = [];
}
$lists['byStore'][$storeId][] = $line;
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
return ['ok' => false, 'error' => 'Failed to save catalog'];
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
return ['ok' => false, 'error' => 'Failed to save grocery list'];
}
return ['ok' => true, 'item' => $line];
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function groceryFirstStoreId(array $stores): string {
if (count($stores) === 0) {
return '';
}
$copy = $stores;
usort($copy, static function ($a, $b) {
$sa = (int) ($a['sort'] ?? 0);
$sb = (int) ($b['sort'] ?? 0);
if ($sa !== $sb) {
return $sa <=> $sb;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return (string) ($copy[0]['id'] ?? '');
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
function normalizeGroceryLineItem(array $item): array {
if (empty($item['id']) || !is_string($item['id'])) {
$item['id'] = bin2hex(random_bytes(8));
}
$item['name'] = trim((string) ($item['name'] ?? ''));
$item['description'] = trim((string) ($item['description'] ?? ''));
$item['size'] = trim((string) ($item['size'] ?? ''));
$item['quantity'] = trim((string) ($item['quantity'] ?? '1'));
$item['price'] = trim((string) ($item['price'] ?? ''));
$item['image'] = trim((string) ($item['image'] ?? ''));
$st = (string) ($item['status'] ?? 'active');
$item['status'] = in_array($st, ['active', 'purchased', 'pending_review'], true) ? $st : 'active';
$item['purchasedAt'] = isset($item['purchasedAt']) && is_string($item['purchasedAt']) ? $item['purchasedAt'] : null;
$item['source'] = in_array($item['source'] ?? 'manual', ['manual', 'meal_plan', 'meal_detail'], true)
? $item['source']
: 'manual';
$item['recurringIntervalDays'] = max(0, (int) ($item['recurringIntervalDays'] ?? 0));
$item['catalogId'] = isset($item['catalogId']) && is_string($item['catalogId']) ? $item['catalogId'] : null;
if (empty($item['addedAt'])) {
$item['addedAt'] = gmdate('c');
}
if (!empty($item['mealId']) && is_string($item['mealId'])) {
$item['mealId'] = trim($item['mealId']);
} else {
unset($item['mealId']);
}
if (isset($item['mealTitle']) && is_string($item['mealTitle'])) {
$item['mealTitle'] = trim($item['mealTitle']);
} else {
unset($item['mealTitle']);
}
return $item;
}
/**
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
*/
function groceryStoreHasItems(array $lists, string $storeId): bool {
$items = $lists['byStore'][$storeId] ?? [];
return is_array($items) && count($items) > 0;
}
/**
* If there are no stores yet, create a single "Home" store and empty list bucket.
*/
function ensureDefaultGroceryStore(): void {
$stores = normalizeStoresList(readJsonFile('stores.json'));
if (count($stores) > 0) {
return;
}
$id = bin2hex(random_bytes(8));
writeJsonFile('stores.json', [['id' => $id, 'name' => 'Home', 'sort' => 0]]);
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$id])) {
$lists['byStore'][$id] = [];
}
writeJsonFile('grocery_lists.json', $lists);
}

View File

@ -4,20 +4,93 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub</title>
<!-- Bootstrap CSS from CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome icons from CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom styles -->
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header class="bg-primary text-white p-3">
<body class="family-hub-body" style="--person-accent: <?= htmlspecialchars($favoriteColor ?? '#4a90e2', ENT_QUOTES, 'UTF-8') ?>;">
<header class="app-header text-white p-3 mb-0">
<div class="container">
<h1>Family Hub</h1>
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-3">
<h1 class="h3 mb-0">Family Hub</h1>
<div class="d-flex flex-column align-items-start align-items-md-end gap-2 ms-md-auto">
<?php
$hdrSym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
$hdrName = trim((string) ($familySettings['currency_name'] ?? ''));
$hdrBal = 0.0;
if ($activePerson !== null) {
$hdrBal = is_numeric($activePerson['currency_balance'] ?? null)
? (float) $activePerson['currency_balance']
: 0.0;
}
?>
<?php if ($activePerson !== null && count($people) > 0): ?>
<div class="user-balance badge rounded-pill bg-light text-dark px-3 py-2 text-wrap text-start" title="Current balance for the selected profile">
<span class="text-muted small">Balance</span><br>
<strong class="fs-6"><?= htmlspecialchars(number_format($hdrBal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong>
<span class="ms-1"><?= htmlspecialchars($hdrSym, ENT_QUOTES, 'UTF-8') ?><?php if ($hdrName !== ''): ?><span class="text-muted small"> <?= htmlspecialchars($hdrName, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?></span>
</div>
<?php endif; ?>
<div class="persona-switcher d-flex flex-wrap gap-2 align-items-center" role="toolbar" aria-label="Who is using the hub">
<?php if (count($people) === 0): ?>
<span class="small opacity-75">Add people in Family settings.</span>
<?php else: ?>
<?php foreach ($people as $p): ?>
<?php
$pid = $p['id'] ?? '';
$pname = $p['name'] ?? 'Unknown';
$isActive = $activePerson && ($activePerson['id'] ?? '') === $pid;
$needsPin = personRequiresPinToActivate($p) ? '1' : '0';
$roleLabel = $p['role'] ?? '';
?>
<button
type="button"
class="btn btn-sm persona-chip <?= $isActive ? 'btn-light active' : 'btn-outline-light' ?>"
data-person-id="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"
data-needs-pin="<?= $needsPin ?>"
data-person-name="<?= htmlspecialchars($pname, ENT_QUOTES, 'UTF-8') ?>"
>
<?= sanitizeInput($pname) ?>
<?php if ($roleLabel === ROLE_HEAD): ?>
<span class="visually-hidden">(Head of household)</span>
<i class="fa fa-house-chimney-user ms-1" aria-hidden="true"></i>
<?php elseif ($roleLabel === ROLE_ADULT): ?>
<i class="fa fa-person ms-1" aria-hidden="true"></i>
<?php elseif ($roleLabel === ROLE_CHILD): ?>
<i class="fa fa-child ms-1" aria-hidden="true"></i>
<?php endif; ?>
</button>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
</header>
<main class="container py-4">
<div class="modal fade" id="hohPinModal" tabindex="-1" aria-labelledby="hohPinModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="hohPinModalLabel">Head of household PIN</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-2" id="hohPinModalPrompt">Enter PIN to switch to this profile.</p>
<label for="hohPinInput" class="form-label">PIN</label>
<input type="password" class="form-control form-control-lg" id="hohPinInput" autocomplete="current-password" minlength="4">
<div class="invalid-feedback d-block d-none" id="hohPinError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="hohPinSubmit">Continue</button>
</div>
</div>
</div>
</div>
<script>
window.familyHubApiBase = <?= json_encode($familyHubApiBase, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP) ?>;
</script>
<main class="container py-4">

298
includes/meal_helpers.php Normal file
View File

@ -0,0 +1,298 @@
<?php
require_once __DIR__ . '/chore_helpers.php';
require_once __DIR__ . '/grocery_helpers.php';
const MEAL_SLOT_BREAKFAST = 'breakfast';
const MEAL_SLOT_LUNCH = 'lunch';
const MEAL_SLOT_DINNER = 'dinner';
/** @return array<int, string> */
function mealSlotTypes(): array {
return [MEAL_SLOT_BREAKFAST, MEAL_SLOT_LUNCH, MEAL_SLOT_DINNER];
}
function mealCurrentWeekStart(): string {
try {
$d = new DateTimeImmutable('monday this week');
return $d->format('Y-m-d');
} catch (Exception $e) {
return gmdate('Y-m-d');
}
}
/**
* @return array<string, array<string, string|null>>
*/
function mealDefaultEmptySlots(): array {
$slots = [];
for ($i = 0; $i < 7; $i++) {
$key = (string) $i;
$slots[$key] = [
MEAL_SLOT_BREAKFAST => null,
MEAL_SLOT_LUNCH => null,
MEAL_SLOT_DINNER => null,
];
}
return $slots;
}
/**
* @param mixed $raw
* @return array{weekStart: string, slots: array<string, array<string, string|null>>}
*/
function normalizeMealPlan($raw): array {
$base = [
'weekStart' => mealCurrentWeekStart(),
'slots' => mealDefaultEmptySlots(),
];
if (!is_array($raw)) {
return $base;
}
$ws = isset($raw['weekStart']) ? trim((string) $raw['weekStart']) : '';
if ($ws !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $ws)) {
$base['weekStart'] = $ws;
}
$slotsIn = $raw['slots'] ?? [];
if (is_array($slotsIn)) {
for ($i = 0; $i < 7; $i++) {
$key = (string) $i;
$day = $slotsIn[$key] ?? $slotsIn[$i] ?? [];
if (!is_array($day)) {
continue;
}
foreach (mealSlotTypes() as $mt) {
$mid = $day[$mt] ?? null;
if ($mid === null || $mid === '') {
$base['slots'][$key][$mt] = null;
} else {
$base['slots'][$key][$mt] = is_string($mid) ? $mid : null;
}
}
}
}
return $base;
}
function loadMealPlan(): array {
return normalizeMealPlan(readJsonFile('meal_plans.json'));
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeMealsList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param array<string, mixed> $m
* @return array<string, mixed>
*/
function normalizeMealRow(array $m): array {
if (isset($m['name']) && !isset($m['title'])) {
$m['title'] = trim((string) $m['name']);
}
$m['title'] = trim((string) ($m['title'] ?? ''));
$m['image'] = trim((string) ($m['image'] ?? ''));
$m['description'] = trim((string) ($m['description'] ?? ''));
$m['directions'] = trim((string) ($m['directions'] ?? ''));
if (!isset($m['lists']) || !is_array($m['lists'])) {
$m['lists'] = [];
}
$m['lists'] = normalizeChoreLists($m['lists']);
$tags = $m['tags'] ?? [];
if (!is_array($tags)) {
$tags = [];
}
$cleanTags = [];
foreach ($tags as $t) {
$t = strtolower(trim((string) $t));
if (in_array($t, ['breakfast', 'lunch', 'dinner'], true)) {
$cleanTags[] = $t;
}
}
if (isset($m['type']) && is_string($m['type']) && count($cleanTags) === 0) {
$tt = strtolower(trim($m['type']));
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
$cleanTags[] = $tt;
}
}
$m['tags'] = array_values(array_unique($cleanTags));
$ing = $m['ingredients'] ?? [];
if (!is_array($ing)) {
$ing = [];
}
$ingOut = [];
foreach ($ing as $line) {
$s = trim((string) $line);
if ($s !== '') {
$ingOut[] = $s;
}
}
$m['ingredients'] = $ingOut;
$items = $m['items'] ?? [];
if (!is_array($items)) {
$items = [];
}
$itemOut = [];
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$nm = trim((string) ($it['name'] ?? ''));
if ($nm === '') {
continue;
}
$itemOut[] = [
'name' => $nm,
'storeId' => trim((string) ($it['storeId'] ?? '')),
'description' => trim((string) ($it['description'] ?? '')),
'size' => trim((string) ($it['size'] ?? '')),
'quantity' => trim((string) ($it['quantity'] ?? '1')) ?: '1',
'price' => trim((string) ($it['price'] ?? '')),
'image' => trim((string) ($it['image'] ?? '')),
];
}
$m['items'] = $itemOut;
if (!isset($m['author_id'])) {
$m['author_id'] = '';
}
return $m;
}
/**
* @param array<int, array<string, mixed>> $meals
*/
function findMealById(array $meals, string $id): ?array {
foreach ($meals as $m) {
if (($m['id'] ?? '') === $id) {
return $m;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $meals
*/
function findMealIndexById(array $meals, string $id): ?int {
foreach ($meals as $i => $m) {
if (($m['id'] ?? '') === $id) {
return $i;
}
}
return null;
}
/**
* Migrate legacy meals (name, date, type) into library format.
*
* @param array<int, array<string, mixed>> $meals
* @return array<int, array<string, mixed>>
*/
function migrateLegacyMealsList(array $meals): array {
$out = [];
foreach ($meals as $m) {
if (!is_array($m)) {
continue;
}
if (isset($m['title']) && isset($m['tags'])) {
$out[] = normalizeMealRow($m);
continue;
}
$title = trim((string) ($m['title'] ?? $m['name'] ?? ''));
if ($title === '') {
continue;
}
$tags = [];
if (!empty($m['type'])) {
$tt = strtolower(trim((string) $m['type']));
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
$tags[] = $tt;
}
}
$out[] = normalizeMealRow([
'id' => !empty($m['id']) && is_string($m['id']) ? $m['id'] : bin2hex(random_bytes(8)),
'title' => $title,
'tags' => $tags,
'description' => isset($m['date']) ? 'Legacy date: ' . (string) $m['date'] : '',
'image' => '',
'directions' => '',
'lists' => [],
'ingredients' => [],
'items' => [],
'author_id' => '',
]);
}
return $out;
}
/**
* Push meal shopping items to grocery lists as pending_review.
*
* @param array<int, array<string, mixed>> $stores
* @return int number of lines added
*/
function pushMealItemsToGrocery(array $meal, array $stores): int {
$items = $meal['items'] ?? [];
if (!is_array($items) || count($items) === 0) {
return 0;
}
$mealId = (string) ($meal['id'] ?? '');
$mealTitle = (string) ($meal['title'] ?? '');
$defaultStore = groceryFirstStoreId($stores);
if ($defaultStore === '') {
return 0;
}
$added = 0;
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$sid = trim((string) ($row['storeId'] ?? ''));
if ($sid === '' || findStoreById($stores, $sid) === null) {
$sid = $defaultStore;
}
$res = groceryAppendShoppingLine(
$stores,
$sid,
$name,
trim((string) ($row['description'] ?? '')),
trim((string) ($row['size'] ?? '')),
trim((string) ($row['quantity'] ?? '1')) ?: '1',
trim((string) ($row['price'] ?? '')),
trim((string) ($row['image'] ?? '')),
'meal_plan',
0,
$mealId !== '' ? $mealId : null,
$mealTitle !== '' ? $mealTitle : null
);
if ($res['ok']) {
$added++;
}
}
return $added;
}
function mealDayShortLabel(string $weekStart, int $offset): string {
$ts = strtotime($weekStart . ' UTC +' . $offset . ' days');
if ($ts === false) {
return (string) $offset;
}
return gmdate('D n/j', $ts);
}

87
includes/persona.php Normal file
View File

@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/db.php';
const ROLE_HEAD = 'head_of_household';
const ROLE_ADULT = 'adult';
const ROLE_CHILD = 'child';
const SESSION_ACTIVE_PERSON = 'active_person_id';
const SESSION_HOH_VERIFIED = 'hoh_verified';
function startFamilyHubSession(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function getActivePersonId(): ?string {
$id = $_SESSION[SESSION_ACTIVE_PERSON] ?? null;
return is_string($id) && $id !== '' ? $id : null;
}
function setSessionPerson(string $personId, bool $hohVerified): void {
$_SESSION[SESSION_ACTIVE_PERSON] = $personId;
$_SESSION[SESSION_HOH_VERIFIED] = $hohVerified;
}
function clearPersonaSession(): void {
unset($_SESSION[SESSION_ACTIVE_PERSON], $_SESSION[SESSION_HOH_VERIFIED]);
}
function isHohVerified(): bool {
return !empty($_SESSION[SESSION_HOH_VERIFIED]);
}
function findPersonById(array $people, string $id): ?array {
foreach ($people as $p) {
if (($p['id'] ?? '') === $id) {
return $p;
}
}
return null;
}
function getActivePerson(array $people): ?array {
$id = getActivePersonId();
if ($id === null) {
return null;
}
return findPersonById($people, $id);
}
function personRequiresPinToActivate(?array $person): bool {
if ($person === null) {
return false;
}
return ($person['role'] ?? '') === ROLE_HEAD && !empty($person['pin_hash']);
}
function assertHoHCanManagePeople(array $people): void {
if (count($people) === 0) {
return;
}
$active = getActivePerson($people);
if ($active === null || ($active['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Head of household verification required.']);
exit;
}
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizePeopleList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}

View File

@ -20,4 +20,16 @@ function ensureExportDirectory() {
if (!file_exists(EXPORT_DESTINATION)) {
mkdir(EXPORT_DESTINATION, 0755, true);
}
}
}
/**
* Base path prefix for JSON API under this app (e.g. /familyHub/api).
*/
function familyHubWebApiBase(): string {
$sd = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
$sd = rtrim(str_replace('\\', '/', $sd), '/');
if ($sd === '' || $sd === '/') {
return '/api';
}
return $sd . '/api';
}

View File

@ -2,26 +2,54 @@
require_once 'config/config.php';
require_once 'includes/db.php';
require_once 'includes/utils.php';
require_once 'includes/persona.php';
require_once 'includes/family_settings.php';
// Determine which tab is active
$activeTab = isset($_GET['tab']) ? $_GET['tab'] : 'chores';
startFamilyHubSession();
$people = normalizePeopleList(readJsonFile('people.json'));
if (getActivePersonId() !== null && getActivePerson($people) === null) {
clearPersonaSession();
}
$activePerson = getActivePerson($people);
$familySettings = loadFamilySettings();
if (isset($TABS['currency'])) {
$TABS['currency']['title'] = currencyTabLabel($familySettings);
}
$activeTab = isset($_GET['tab']) ? (string) $_GET['tab'] : 'chores';
if (!isset($TABS[$activeTab])) {
$activeTab = 'chores';
}
$familyHubApiBase = familyHubWebApiBase();
$favoriteColor = '#4a90e2';
if (
$activePerson !== null
&& !empty($activePerson['favoriteColor'])
&& preg_match('/^#[0-9A-Fa-f]{6}$/', (string) $activePerson['favoriteColor'])
) {
$favoriteColor = (string) $activePerson['favoriteColor'];
}
// Include header
include 'includes/header.php';
?>
<div class="container">
<div class="tabs">
<?php foreach ($TABS as $tabId => $tab): ?>
<a href="?tab=<?= $tabId ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>">
<i class="fa fa-<?= $tab['icon'] ?>"></i> <?= $tab['title'] ?>
<a href="?tab=<?= htmlspecialchars($tabId, ENT_QUOTES, 'UTF-8') ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>">
<i class="fa fa-<?= htmlspecialchars($tab['icon'], ENT_QUOTES, 'UTF-8') ?>"></i> <?= htmlspecialchars($tab['title'], ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endforeach; ?>
</div>
<div class="tab-content">
<?php include "tabs/$activeTab.php"; ?>
<?php include 'tabs/' . $activeTab . '.php'; ?>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/footer.php'; ?>

227
install.php Normal file
View File

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
$rootPath = __DIR__;
$envPath = $rootPath . '/.env';
$templatePath = $rootPath . '/env.example';
/**
* Serialize a value for a dotenv-style line; quote when needed.
*/
function installFormatEnvValue(string $value): string {
if ($value === '' || preg_match('/[\s#=\\\\"]/', $value) === 1) {
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"' . $escaped . '"';
}
return $value;
}
/**
* Strip matching outer quotes from env.example defaults for display.
*/
function installUnquoteDefault(string $value): string {
$value = trim($value);
$len = strlen($value);
if ($len >= 2) {
if ($value[0] === '"' && $value[$len - 1] === '"') {
return substr($value, 1, -1);
}
if ($value[0] === "'" && $value[$len - 1] === "'") {
return substr($value, 1, -1);
}
}
return $value;
}
/**
* @return list<array{section: string|null, key: string, default: string}>
*/
function installParseTemplateForForm(string $templatePath): array {
$raw = file($templatePath, FILE_IGNORE_NEW_LINES);
if ($raw === false) {
return [];
}
$entries = [];
$section = null;
foreach ($raw as $line) {
$trim = trim($line);
if ($trim === '') {
continue;
}
if (strpos($trim, '#') === 0) {
$section = trim(substr($trim, 1));
continue;
}
if (strpos($line, '=') === false) {
continue;
}
[$name, $val] = explode('=', $line, 2);
$name = trim($name);
$entries[] = [
'section' => $section,
'key' => $name,
'default' => installUnquoteDefault($val),
];
}
return $entries;
}
function installWriteEnvFromTemplate(string $templatePath, string $envPath, array $postEnv): bool {
$raw = file($templatePath, FILE_IGNORE_NEW_LINES);
if ($raw === false) {
return false;
}
$out = [];
foreach ($raw as $line) {
$trim = trim($line);
if ($trim === '' || strpos($trim, '#') === 0) {
$out[] = $line;
continue;
}
if (strpos($line, '=') === false) {
$out[] = $line;
continue;
}
[$name, $templateVal] = explode('=', $line, 2);
$name = trim($name);
if (array_key_exists($name, $postEnv)) {
$submitted = (string) $postEnv[$name];
} else {
$submitted = installUnquoteDefault($templateVal);
}
$out[] = $name . '=' . installFormatEnvValue($submitted);
}
$bytes = file_put_contents($envPath, implode("\n", $out) . "\n", LOCK_EX);
if ($bytes === false) {
return false;
}
@chmod($envPath, 0600);
return true;
}
// Already configured
if (file_exists($envPath)) {
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub Setup</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="col-lg-8 mx-auto">
<h1 class="h3 mb-3">Family Hub</h1>
<p class="text-muted">This application is already configured (<code>.env</code> exists).</p>
<a href="index.php" class="btn btn-primary">Go to Family Hub</a>
</div>
</div>
</body>
</html>
<?php
exit;
}
if (!is_readable($templatePath)) {
header('Content-Type: text/html; charset=UTF-8');
http_response_code(500);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub Setup error</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="col-lg-8 mx-auto">
<h1 class="h3 mb-3 text-danger">Setup cannot continue</h1>
<p>The template file <code>env.example</code> is missing or not readable. Restore it from the repository.</p>
</div>
</div>
</body>
</html>
<?php
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postEnv = $_POST['env'] ?? [];
if (!is_array($postEnv)) {
$postEnv = [];
}
if (installWriteEnvFromTemplate($templatePath, $envPath, $postEnv)) {
header('Location: index.php', true, 302);
exit;
}
$writeError = true;
} else {
$writeError = false;
}
$entries = installParseTemplateForForm($templatePath);
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub Environment setup</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-light">
<div class="container py-4">
<div class="col-lg-8 mx-auto">
<header class="mb-4">
<h1 class="h3"><i class="fa fa-sliders text-primary me-2"></i>Environment setup</h1>
<p class="text-muted mb-0">Values are generated from <code>env.example</code>. Fill in your credentials and save.</p>
</header>
<?php if (!empty($writeError)): ?>
<div class="alert alert-danger">Could not write <code>.env</code>. Check that the app directory is writable by the web server.</div>
<?php endif; ?>
<form method="post" class="card shadow-sm">
<div class="card-body">
<?php
$lastSection = null;
foreach ($entries as $row):
if ($row['section'] !== $lastSection && $row['section'] !== null && $row['section'] !== ''):
$lastSection = $row['section'];
?>
<h2 class="h6 text-uppercase text-secondary border-bottom pb-2 mt-4 first-section"><?= htmlspecialchars($row['section'], ENT_QUOTES, 'UTF-8') ?></h2>
<?php
elseif ($row['section'] !== $lastSection):
$lastSection = $row['section'];
endif;
$key = $row['key'];
$val = $row['default'];
?>
<div class="mb-3">
<label class="form-label font-monospace small mb-1" for="env_<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?></label>
<input type="text" class="form-control font-monospace" name="env[<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>]" id="env_<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>" value="<?= htmlspecialchars($val, ENT_QUOTES, 'UTF-8') ?>" autocomplete="off">
</div>
<?php endforeach; ?>
</div>
<div class="card-footer bg-white border-top-0 d-flex gap-2">
<button type="submit" class="btn btn-primary">Save and continue</button>
</div>
</form>
</div>
</div>
<style>.first-section:first-child { margin-top: 0 !important; }</style>
</body>
</html>

119
readme.md
View File

@ -1,47 +1,120 @@
# Family Hub
A centralized family organization system with tabs for chores, grocery lists, and meal planning.
A centralized family organization system with tabs for chores, groceries, meal planning, family currency, and settings.
## Features
- Tabbed interface for different family needs
- JSON-based data storage
- Daily automated exports
- Tabbed interface for chores, groceries, meals, shared Google Calendar 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
## Setup
1. Clone this repository to your web server
2. Ensure proper permissions on data directory
3. Set up the daily cron job for exports
4. Access the hub at http://your-local-ip/familyHub/
1. Clone this repository to your web server.
2. **Configure the environment**
- With a browser, open the app URL (for example `http://your-server/familyHub/`). If `.env` is missing, you are redirected to **`install.php`**, which builds `.env` from **`env.example`**.
- Alternatively, copy `env.example` to `.env` and edit `.env` by hand.
- See [Environment variables](#environment-variables) below for where to get each value.
3. Ensure proper permissions on the `data` directory (and that the web server can create or write JSON files there).
4. Set up the daily cron job for exports (see `scripts/daily_export.php`). It writes one JSON file per type into `exports/` (for example `chores`, `groceries`, `meals`, **`meal_plans`**, `expenses`, `stores`, `grocery_lists`, `grocery_catalog`). From the command line, the script exits with an error if `.env` is missing; complete browser setup first or copy `env.example` manually.
5. Access the hub at `http://your-local-ip/familyHub/` (adjust path and host to match your deployment).
### Google Cloud prerequisites (OAuth + APIs)
Most Google-related values come from **[Google Cloud Console](https://console.cloud.google.com/)**.
1. Create or select a **project**.
2. Open **APIs & Services → Library**, then enable any APIs you rely on (for example **Google Calendar API** and **Google Drive API** if you use those integrations).
3. Open **APIs & Services → OAuth consent screen** and complete it for your use case (Internal for Workspace-only, External for broader testing; test users may be required until published).
4. Open **APIs & Services → Credentials → Create credentials → OAuth client ID**. Choose **Web application** if your app uses a browser redirect.
5. Under **Authorized redirect URIs**, add the exact URL you will put in **`GOOGLE_REDIRECT_URI`** in `.env` (scheme, host, path, no trailing slash unless your app uses one).
Use the official [Google Cloud OAuth documentation](https://developers.google.com/identity/protocols/oauth2) for details if anything in the console changes.
## Environment variables
Values are defined in **`env.example`**. The install wizard reads that file only; use this section to learn **where to obtain** each value.
### Google API credentials
| Variable | What it is | Where to find it |
| -------- | ----------- | ---------------- |
| **`GOOGLE_CLIENT_ID`** | Public OAuth 2.0 client ID for your app | **Google Cloud Console → APIs & Services → Credentials**. Open your **OAuth 2.0 Client ID** (Web application). Copy **Client ID**. |
| **`GOOGLE_CLIENT_SECRET`** | Secret for the same OAuth client | Same credential row. Copy **Client secret** (treat it like a password; never commit it). |
| **`GOOGLE_REDIRECT_URI`** | URL Google redirects to after OAuth | Must match **exactly** one of **Authorized redirect URIs** on that OAuth client. Set it to your real callback URL (e.g. `https://yourdomain.com/family-hub/auth/google/callback`). Use the same scheme and path your server exposes. |
### Google Calendar
The **Calendar** tab embeds your family calendar in the hub using these values (HTTPS Google Calendar URLs only; optional iframe `src` is extracted from pasted HTML).
| Variable | What it is | Where to find it |
| -------- | ----------- | ---------------- |
| **`GOOGLE_CALENDAR_ID`** | ID of the calendar to target (APIs, sharing, embeds) | **[Google Calendar](https://calendar.google.com/)** → hover the calendar in the sidebar → **⋮** → **Settings and sharing** → scroll to **Integrate calendar****Calendar ID** (often an email or `...@group.calendar.google.com`). If **`GOOGLE_CALENDAR_EMBED_CODE`** is unset, the tab builds a standard embed URL from this ID. |
| **`GOOGLE_CALENDAR_EMBED_CODE`** | Embed URL or full iframe HTML from Google | Same **Settings and sharing** page → **Integrate calendar** → copy the **embed code** (iframe) or the `https://calendar.google.com/...` URL from the `src` attribute. Prefer this when you want Googles chosen view options. |
### Google Drive
| Variable | What it is | Where to find it |
| -------- | ----------- | ---------------- |
| **`GOOGLE_DRIVE_FOLDER_ID`** | Folder used as export or attachment root | **[Google Drive](https://drive.google.com/)** → open the folder in the browser. The URL looks like `https://drive.google.com/drive/folders/THIS_PART_IS_THE_ID` — copy **`THIS_PART_IS_THE_ID`**. |
### Application settings
| Variable | What it is | Where to find it |
| -------- | ----------- | ---------------- |
| **`APP_ENV`** | Logical environment name | Your choice (e.g. `development`, `staging`, `production`). Used for app behavior toggles if the code checks it. |
| **`APP_DEBUG`** | Verbose errors / debug mode | `true` or `false` (strings as in `env.example`). Only use **`true`** on trusted local or staging servers. |
| **`APP_URL`** | Base URL of this app | Same origin users use in the browser (e.g. `https://example.com/family-hub`), no trailing path beyond the app root unless your deployment requires it. Should align with **`GOOGLE_REDIRECT_URI`** host/path where applicable. |
### Export settings
| Variable | What it is | Where to find it |
| -------- | ----------- | ---------------- |
| **`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`). |
## Directory Structure
familyHub/
├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, …)
├── assets/ # Static assets
│ ├── css/ # CSS files
│ │ └── style.css
│ ├── js/ # JavaScript files
│ │ └── main.js
│ │ └── main.js
│ └── img/ # Images
├── config/ # Configuration files
│ └── config.php
│ ├── config.php
│ └── env.php # Loads .env
├── data/ # JSON data storage (not tracked in git)
│ ├── chores.json
│ ├── groceries.json
│ └── meals.json
├── includes/ # PHP includes/components
│ ├── chores.json # Chores, assignments, submissions, reviews
│ ├── people.json # Profiles, roles, PIN hashes, currency balances
│ ├── family_settings.json # Currency labels, timezone, week start, etc.
│ ├── expenses.json # Head-of-household expense log
│ ├── stores.json # Grocery store definitions
│ ├── grocery_lists.json # Per-store shopping lines
│ ├── grocery_catalog.json # Deduped catalog / recurring metadata
│ ├── groceries.json # Legacy single-file groceries; migrated when detected
│ ├── meals.json # Meal library (titles, tags, ingredients, shopping items)
│ └── meal_plans.json # Active weekly plan: `weekStart` (Monday YYYY-MM-DD) and `slots` 06 × breakfast/lunch/dinner → meal id or empty
├── includes/ # PHP includes (db, persona, tab helpers, `api_bootstrap.php`, …)
│ ├── header.php
│ ├── footer.php
│ ├── db.php # JSON file handling functions
│ └── utils.php # Utility functions
├── exports/ # Temporary location for exports (not tracked in git)
├── scripts/ # Scripts for cron jobs
│ ├── db.php # JSON file I/O (flock)
│ └── utils.php # Shared helpers
├── exports/ # Export output (not tracked in git)
├── scripts/ # Cron / CLI helpers
│ └── daily_export.php
├── tabs/ # Tab-specific functionality
├── tabs/ # Tab views included by `index.php`
│ ├── chores.php
│ ├── groceries.php
│ └── meals.php
│ ├── meals.php
│ ├── calendar.php
│ ├── currency.php
│ └── settings.php
├── env.example # Environment template (parsed by install.php)
├── install.php # Web wizard to create .env when missing
├── .gitignore
├── .cursor.json # Cursor editor configuration
├── README.md
├── .cursor/ # Cursor editor configuration and rules
├── readme.md
├── index.php # Main entry point
└── export.php # Export functionality
├── AGENTS.md # Agent / contributor notes
└── export.php # `?type=` JSON export (same types as daily script)

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../config/config.php';
// Export all data types
$types = ['chores', 'groceries', 'meals'];
$types = ['chores', 'groceries', 'meals', 'meal_plans', 'expenses', 'stores', 'grocery_lists', 'grocery_catalog'];
$results = [];
foreach ($types as $type) {

View File

@ -0,0 +1,71 @@
<?php
/**
* Add recurring catalog items back to the active list when nextDueDate is today or past.
* Run from cron after daily_export or on its own.
*/
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
if (!file_exists(dirname(__DIR__) . '/.env')) {
fwrite(STDERR, "Family Hub: .env missing.\n");
exit(1);
}
migrateLegacyGroceriesIfNeeded();
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$today = gmdate('Y-m-d');
$added = 0;
foreach ($catalog as $ci => $c) {
$next = isset($c['nextDueDate']) ? trim((string) $c['nextDueDate']) : '';
$interval = max(0, (int) ($c['recurringIntervalDays'] ?? 0));
if ($next === '' || $interval < 1) {
continue;
}
if (strcmp($next, $today) > 0) {
continue;
}
$storeId = (string) ($c['storeId'] ?? '');
if ($storeId === '') {
continue;
}
$name = trim((string) ($c['name'] ?? ''));
if ($name === '') {
continue;
}
$line = normalizeGroceryLineItem([
'id' => bin2hex(random_bytes(8)),
'catalogId' => (string) ($c['id'] ?? ''),
'name' => $name,
'description' => trim((string) ($c['description'] ?? '')),
'size' => trim((string) ($c['defaultSize'] ?? '')),
'quantity' => '1',
'price' => '',
'image' => trim((string) ($c['defaultImage'] ?? '')),
'status' => 'active',
'purchasedAt' => null,
'source' => 'manual',
'recurringIntervalDays' => $interval,
'addedAt' => gmdate('c'),
]);
if (!isset($lists['byStore'][$storeId])) {
$lists['byStore'][$storeId] = [];
}
$lists['byStore'][$storeId][] = $line;
$catalog[$ci]['nextDueDate'] = gmdate('Y-m-d', strtotime('+' . $interval . ' days'));
$added++;
}
if ($added > 0) {
writeJsonFile('grocery_catalog.json', $catalog);
writeJsonFile('grocery_lists.json', $lists);
}
fwrite(STDOUT, 'grocery_recurring: added ' . $added . " item(s).\n");

67
tabs/calendar.php Normal file
View File

@ -0,0 +1,67 @@
<?php
$rawEmbed = defined('GOOGLE_CALENDAR_EMBED_CODE') ? trim((string) GOOGLE_CALENDAR_EMBED_CODE) : '';
$calId = defined('GOOGLE_CALENDAR_ID') ? trim((string) GOOGLE_CALENDAR_ID) : '';
/**
* @return string|null HTTPS embed URL restricted to Google Calendar hosts
*/
function familyHub_google_calendar_embed_url(string $raw): ?string {
$raw = trim($raw);
if ($raw === '') {
return null;
}
$allowed = static function (string $u): bool {
$p = parse_url($u);
if ($p === false || empty($p['scheme']) || empty($p['host'])) {
return false;
}
if (strtolower($p['scheme']) !== 'https') {
return false;
}
$h = strtolower($p['host']);
if ($h === 'calendar.google.com') {
return true;
}
if ($h === 'www.google.com' && isset($p['path']) && str_starts_with($p['path'], '/calendar')) {
return true;
}
return false;
};
if ($allowed($raw)) {
return $raw;
}
if (preg_match('#<iframe\s[^>]*\ssrc\s*=\s*["\']([^"\']+)["\']#i', $raw, $m)) {
$u = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $allowed($u) ? $u : null;
}
return null;
}
$embedUrl = familyHub_google_calendar_embed_url($rawEmbed);
$fromIdOnly = false;
if ($embedUrl === null && $calId !== '' && $calId !== 'your_calendar_id_here') {
$embedUrl = 'https://calendar.google.com/calendar/embed?src=' . rawurlencode($calId);
$fromIdOnly = true;
}
?>
<div id="calendar" class="tab-content">
<h2 class="mb-2">Calendar</h2>
<?php if ($embedUrl === null): ?>
<p class="text-muted">Connect a shared family calendar by setting <strong>GOOGLE_CALENDAR_EMBED_CODE</strong> (embed URL or full <code>&lt;iframe&gt;</code> from Google Calendar) or <strong>GOOGLE_CALENDAR_ID</strong> in <code>.env</code>. See <a href="https://calendar.google.com/">Google Calendar</a> calendar menu <strong>Settings and sharing</strong> <strong>Integrate calendar</strong>.</p>
<?php else: ?>
<?php if ($fromIdOnly): ?>
<p class="small text-muted mb-2">Showing calendar from <code>GOOGLE_CALENDAR_ID</code>. For more control (view, height), paste the embed URL or iframe into <code>GOOGLE_CALENDAR_EMBED_CODE</code> instead.</p>
<?php endif; ?>
<div class="calendar-embed rounded border overflow-hidden bg-white">
<iframe
class="w-100 border-0 d-block"
style="height: 70vh; min-height: 560px;"
src="<?= htmlspecialchars($embedUrl, ENT_QUOTES, 'UTF-8') ?>"
loading="lazy"
title="Family calendar"
allowfullscreen
></iframe>
</div>
<?php endif; ?>
</div>

View File

@ -1,25 +1,313 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../includes/persona.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
$chores = readJsonFile('chores.json');
$rawChores = readJsonFile('chores.json');
$chores = migrateAllChores(normalizeChoresList($rawChores), $people);
$nameById = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
}
}
$currencySymbol = $familySettings['currency_symbol'] ?? '★';
$canReview = $activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified();
$actorId = $activePerson['id'] ?? '';
$editId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
$editChore = $editId !== '' ? findChoreById($chores, $editId) : null;
$editNotFound = $editId !== '' && $editChore === null;
$editChoreAllowed = $editChore !== null && $activePerson !== null && (
(string) ($editChore['author_id'] ?? '') === (string) $actorId
|| $canReview
);
$showChoreForm = $activePerson !== null
&& !$editNotFound
&& (
($editChore !== null && $editChoreAllowed)
|| ($editChore === null && $canReview)
);
$pendingForReview = [];
$activeChores = [];
$completedChores = [];
foreach ($chores as $c) {
$st = (string) ($c['status'] ?? 'active');
$pend = $c['pending_submission'] ?? null;
if ($st === 'active' && is_array($pend) && $canReview) {
$pendingForReview[] = $c;
}
if ($st === 'completed') {
$completedChores[] = $c;
} elseif ($st === 'active') {
$activeChores[] = $c;
}
}
$prefillList = ['type' => 'checkbox', 'items' => []];
if ($editChore) {
$lists = $editChore['lists'] ?? [];
if (is_array($lists) && count($lists) > 0) {
$b = $lists[0];
$t = $b['type'] ?? 'checkbox';
$prefillList['type'] = in_array($t, ['ordered', 'unordered', 'checkbox'], true) ? $t : 'checkbox';
$items = $b['items'] ?? [];
if (is_array($items)) {
foreach ($items as $it) {
$prefillList['items'][] = (string) $it;
}
}
}
}
?>
<div id="chores" class="tab-content">
<h2>Chores</h2>
<div class="chores-list">
<?php if (empty($chores)): ?>
<p>No chores added yet.</p>
<?php else: ?>
<ul>
<?php foreach ($chores as $chore): ?>
<li>
<span class="chore-name"><?php echo sanitizeInput($chore['name']); ?></span>
<span class="chore-assignee"><?php echo sanitizeInput($chore['assignee']); ?></span>
<span class="chore-due-date"><?php echo formatDate($chore['due_date']); ?></span>
<h2 class="mb-2">Chores</h2>
<?php if ($activePerson !== null): ?>
<div class="alert alert-info small mb-3 chore-howto">
<strong>How to finish a chore:</strong> The checklist bullets are reminders only (not clickable).
If this chore is assigned to you, use <strong>Submit for approval</strong> on the card when the work is done.
A Head of household then <strong>approves</strong> it so the reward is added to balances.
<span class="d-block mt-1 text-muted">Use the <strong>person chips at the top</strong> to switch to the right family member if you dont see that button.</span>
<span class="d-block mt-2"><strong>New chores:</strong> Only a verified <strong>Head of household</strong> can add them (switch profile and enter PIN if needed).</span>
</div>
<?php endif; ?>
<?php if ($activePerson === null): ?>
<div class="alert alert-warning">Choose who is using the hub (top of the page) to work with chores.</div>
<?php endif; ?>
<?php if ($canReview && count($pendingForReview) > 0): ?>
<div class="card border-warning mb-4">
<div class="card-header bg-warning-subtle">Waiting for your approval</div>
<ul class="list-group list-group-flush">
<?php foreach ($pendingForReview as $c): ?>
<?php
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
$sub = $c['pending_submission'] ?? [];
$byId = is_array($sub) ? (string) ($sub['submitted_by'] ?? '') : '';
$byName = $nameById[$byId] ?? $byId;
?>
<li class="list-group-item d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2">
<div>
<strong><?= sanitizeInput($c['title'] ?? '') ?></strong>
<span class="text-muted small ms-1"> submitted by <?= sanitizeInput($byName) ?></span>
<div class="small text-muted"><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?> · assignees:
<?php
$ids = $c['assignee_ids'] ?? [];
$nm = [];
foreach ($ids as $i) {
$nm[] = $nameById[(string) $i] ?? (string) $i;
}
echo sanitizeInput(implode(', ', $nm));
?>
</div>
</div>
<div class="btn-group">
<button type="button" class="btn btn-success btn-sm btn-chore-approve" data-id="<?= $cid ?>">Approve</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-chore-reject" data-id="<?= $cid ?>">Reject</button>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($editNotFound): ?>
<div class="alert alert-warning">That chore was not found. <a href="?tab=chores">Back to chores</a></div>
<?php endif; ?>
<?php if ($showChoreForm): ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><?= $editChore ? 'Edit chore' : 'New chore' ?></span>
<?php if ($editChore): ?>
<a href="?tab=chores" class="btn btn-sm btn-outline-secondary">Cancel edit</a>
<?php endif; ?>
</div>
<div class="card-body">
<form id="choreForm" class="row g-3">
<input type="hidden" name="id" id="chore_id" value="<?= $editChore ? htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') : '' ?>">
<div class="col-md-8">
<label class="form-label" for="chore_title">Title</label>
<input class="form-control" id="chore_title" required
value="<?= $editChore ? sanitizeInput($editChore['title'] ?? '') : '' ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="chore_value">Reward (<?= sanitizeInput($currencySymbol) ?>)</label>
<input type="number" class="form-control" id="chore_value" min="0" step="0.01"
value="<?= $editChore ? htmlspecialchars((string) ($editChore['value'] ?? '0'), ENT_QUOTES, 'UTF-8') : '0' ?>">
</div>
<div class="col-12">
<label class="form-label" for="chore_description">Description</label>
<textarea class="form-control" id="chore_description" rows="2"><?= $editChore ? sanitizeInput($editChore['description'] ?? '') : '' ?></textarea>
</div>
<div class="col-md-8">
<label class="form-label" for="chore_image">Image URL (optional)</label>
<input class="form-control" id="chore_image" type="url" placeholder="https://…"
value="<?= $editChore ? sanitizeInput($editChore['image'] ?? '') : '' ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="chore_due_date">Due date</label>
<input class="form-control" id="chore_due_date" type="date"
value="<?= $editChore ? sanitizeInput($editChore['due_date'] ?? '') : '' ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="chore_schedule">Schedule</label>
<select class="form-select" id="chore_schedule">
<option value="once" <?= !$editChore || ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_ONCE ? 'selected' : '' ?>>One-time</option>
<option value="recurring" <?= $editChore && ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'selected' : '' ?>>Recurring</option>
</select>
</div>
<div class="col-md-6" id="chore_recurrence_wrap">
<label class="form-label" for="chore_recurrence_days">Days until next due (recurring)</label>
<input type="number" class="form-control" id="chore_recurrence_days" min="1" value="<?= $editChore ? (int) ($editChore['recurrence_days'] ?? 7) : 7 ?>">
</div>
<div class="col-12">
<label class="form-label">Assign to</label>
<div class="d-flex flex-wrap gap-3">
<?php foreach ($people as $p): ?>
<?php
$pid = (string) ($p['id'] ?? '');
if ($pid === '') {
continue;
}
$checked = $editChore && is_array($editChore['assignee_ids'] ?? null) && in_array($pid, $editChore['assignee_ids'], true);
?>
<div class="form-check">
<input class="form-check-input chore-assignee" type="checkbox" value="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" id="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" <?= $checked ? 'checked' : '' ?>>
<label class="form-check-label" for="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="chore_list_type">Checklist / list type</label>
<select class="form-select" id="chore_list_type">
<option value="checkbox" <?= $prefillList['type'] === 'checkbox' ? 'selected' : '' ?>>Checkbox list</option>
<option value="unordered" <?= $prefillList['type'] === 'unordered' ? 'selected' : '' ?>>Bullet list</option>
<option value="ordered" <?= $prefillList['type'] === 'ordered' ? 'selected' : '' ?>>Numbered list</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label" for="chore_list_items">List items (one per line)</label>
<p class="form-text small mb-1">Shown on the card as a numbered, bullet, or checkbox-style list. Assignees tick these off in real life; finishing the chore is the <strong>Submit for approval</strong> button on the card.</p>
<textarea class="form-control" id="chore_list_items" rows="4" placeholder="Take out recycling&#10;Wipe counters"><?= sanitizeInput(implode("\n", $prefillList['items'])) ?></textarea>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-primary"><?= $editChore ? 'Save changes' : 'Add chore' ?></button>
<?php if ($editChore): ?>
<button type="button" class="btn btn-outline-danger" id="choreDeleteBtn" data-id="<?= htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Delete chore</button>
<?php endif; ?>
</div>
<div class="col-12">
<div class="alert d-none" id="choreFormFeedback" role="status"></div>
</div>
</form>
</div>
</div>
</div>
<?php elseif ($activePerson !== null && $editChore !== null && !$editChoreAllowed): ?>
<div class="alert alert-warning mb-4">You cant edit this chore. Only the person who created it or a verified Head of household can.</div>
<?php elseif ($activePerson !== null && $editChore === null && !$canReview): ?>
<p class="text-muted small mb-4">Switch to a verified <strong>Head of household</strong> (top of the page) to add new chores.</p>
<?php endif; ?>
<h3 class="h5 mt-4">Active chores</h3>
<?php if (count($activeChores) === 0): ?>
<p class="text-muted">No active chores.</p>
<?php else: ?>
<div class="row g-3">
<?php foreach ($activeChores as $c): ?>
<?php
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
$assignees = $c['assignee_ids'] ?? [];
$isAssignee = $actorId !== '' && is_array($assignees) && in_array((string) $actorId, $assignees, true);
$canEdit = $activePerson && ((string) ($c['author_id'] ?? '') === (string) $actorId || $canReview);
$pending = is_array($c['pending_submission'] ?? null);
?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card chore-card h-100">
<?php if (!empty($c['image']) && preg_match('#^https?://#i', (string) $c['image'])): ?>
<img src="<?= sanitizeInput($c['image']) ?>" class="card-img-top chore-thumb" alt="">
<?php endif; ?>
<div class="card-body">
<h4 class="h6 card-title"><?= sanitizeInput($c['title'] ?? '') ?></h4>
<p class="card-text small"><?= nl2br(sanitizeInput($c['description'] ?? '')) ?></p>
<p class="small text-muted mb-1">
Reward: <strong><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?></strong>
<?php if (!empty($c['due_date'])): ?>
· Due <?= sanitizeInput($c['due_date']) ?>
<?php endif; ?>
</p>
<p class="small text-muted mb-2">
<?= ($c['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'Recurring' : 'One-time' ?>
<?php if ($pending): ?>
<span class="badge text-bg-warning">Waiting for approval</span>
<?php endif; ?>
</p>
<?php if (!empty($c['lists']) && is_array($c['lists'])): ?>
<p class="small text-muted mb-1"><strong>Steps</strong> (reference use the button below when youre done)</p>
<?php endif; ?>
<?php foreach (($c['lists'] ?? []) as $block): ?>
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
<?php $t = $block['type'] ?? 'checkbox'; ?>
<?php if ($t === 'ordered'): ?>
<ol class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
<?php elseif ($t === 'unordered'): ?>
<ul class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
<?php else: ?>
<ul class="list-unstyled small mb-2 chore-checklist-display"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1" aria-hidden="true"></i><span><?= sanitizeInput((string) $it) ?></span></li><?php endforeach; ?></ul>
<?php endif; ?>
<?php endforeach; ?>
<div class="d-flex flex-wrap gap-2 mt-auto align-items-center">
<?php if ($isAssignee && !$pending): ?>
<button type="button" class="btn btn-sm btn-success btn-chore-submit" data-id="<?= $cid ?>">Submit for approval</button>
<?php elseif ($activePerson !== null && !$pending && !$isAssignee): ?>
<?php
$nmAssign = [];
foreach ($assignees as $aid) {
$nmAssign[] = $nameById[(string) $aid] ?? (string) $aid;
}
$who = sanitizeInput(implode(', ', $nmAssign));
?>
<span class="small text-muted">To submit when done: switch to <?= $who !== '' ? $who : 'an assignee' ?> at the top.</span>
<?php endif; ?>
<?php if ($canEdit): ?>
<a class="btn btn-sm btn-outline-primary" href="?tab=chores&amp;edit=<?= $cid ?>">Edit</a>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (count($completedChores) > 0): ?>
<details class="mt-4">
<summary class="h6">Completed chores (<?= count($completedChores) ?>)</summary>
<ul class="list-group mt-2">
<?php foreach ($completedChores as $c): ?>
<li class="list-group-item text-muted"><?= sanitizeInput($c['title'] ?? '') ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</div>

178
tabs/currency.php Normal file
View File

@ -0,0 +1,178 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../includes/persona.php';
require_once __DIR__ . '/../includes/expense_helpers.php';
require_once __DIR__ . '/../includes/family_settings.php';
$sym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
$name = trim((string) ($familySettings['currency_name'] ?? ''));
$tabTitle = currencyTabLabel($familySettings);
$canRecordExpense = $activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified();
$leaderboard = $people;
usort($leaderboard, static function ($a, $b) {
$ba = is_numeric($a['currency_balance'] ?? null) ? (float) $a['currency_balance'] : 0.0;
$bb = is_numeric($b['currency_balance'] ?? null) ? (float) $b['currency_balance'] : 0.0;
$cmp = $bb <=> $ba;
if ($cmp !== 0) {
return $cmp;
}
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
$nameById = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
}
}
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
usort($expenses, static function ($a, $b) {
$da = (string) ($a['date'] ?? '');
$db = (string) ($b['date'] ?? '');
if ($db !== $da) {
return strcmp($db, $da);
}
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
});
$recentExpenses = array_slice($expenses, 0, 50);
$today = gmdate('Y-m-d');
?>
<div id="currency" class="tab-content">
<h2 class="mb-2"><?= htmlspecialchars($tabTitle, ENT_QUOTES, 'UTF-8') ?></h2>
<p class="text-muted small mb-4">Leaderboard and expenses use the family currency from Family settings.</p>
<div class="row g-4">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">
<i class="fa fa-trophy me-1" aria-hidden="true"></i> Leaderboard
</div>
<div class="card-body p-0">
<?php if (count($leaderboard) === 0): ?>
<p class="p-3 text-muted mb-0">Add people in Family settings to see balances.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped mb-0 leaderboard-table">
<thead>
<tr>
<th scope="col" class="text-muted">#</th>
<th scope="col">Name</th>
<th scope="col" class="text-end">Balance</th>
</tr>
</thead>
<tbody>
<?php foreach ($leaderboard as $rank => $p): ?>
<?php
$pid = (string) ($p['id'] ?? '');
$bal = is_numeric($p['currency_balance'] ?? null) ? (float) $p['currency_balance'] : 0.0;
$isSelf = $activePerson && ($activePerson['id'] ?? '') === $pid;
?>
<tr class="<?= $isSelf ? 'table-primary' : '' ?>">
<td class="text-muted"><?= (int) $rank + 1 ?></td>
<td><?= sanitizeInput($p['name'] ?? '') ?><?php if ($isSelf): ?> <span class="badge text-bg-info">You</span><?php endif; ?></td>
<td class="text-end text-nowrap"><strong><?= htmlspecialchars(number_format($bal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-lg-7">
<?php if ($canRecordExpense): ?>
<div class="card border-secondary mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa fa-minus-circle me-1"></i> Record expense</span>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#expenseFormCollapse" aria-expanded="false" aria-controls="expenseFormCollapse">
Show form
</button>
</div>
<div class="collapse" id="expenseFormCollapse">
<div class="card-body">
<p class="small text-muted">Deducts from one persons balance (e.g. spent allowance). Requires enough balance.</p>
<form id="expenseForm" class="row g-3">
<div class="col-md-6">
<label class="form-label" for="expense_title">Title</label>
<input class="form-control" id="expense_title" required maxlength="120">
</div>
<div class="col-md-6">
<label class="form-label" for="expense_value">Amount (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
<input type="number" class="form-control" id="expense_value" min="0.01" step="0.01" required>
</div>
<div class="col-md-6">
<label class="form-label" for="expense_date">Date</label>
<input type="date" class="form-control" id="expense_date" value="<?= htmlspecialchars($today, ENT_QUOTES, 'UTF-8') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="expense_assignee">Charged to</label>
<select class="form-select" id="expense_assignee" required>
<option value="">Choose person…</option>
<?php foreach ($people as $p): ?>
<?php if (empty($p['id'])) { continue; } ?>
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label" for="expense_description">Description</label>
<textarea class="form-control" id="expense_description" rows="2"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-danger">Apply expense</button>
</div>
<div class="col-12">
<div class="alert d-none" id="expenseFormFeedback" role="status"></div>
</div>
</form>
</div>
</div>
</div>
<?php elseif (count($people) > 0): ?>
<p class="text-muted small">Switch to a verified Head of household to record expenses.</p>
<?php endif; ?>
<div class="card">
<div class="card-header">Recent expenses</div>
<div class="card-body p-0">
<?php if (count($recentExpenses) === 0): ?>
<p class="p-3 text-muted mb-0">No expenses recorded yet.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>To</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentExpenses as $ex): ?>
<tr>
<td class="text-nowrap"><?= sanitizeInput((string) ($ex['date'] ?? '')) ?></td>
<td><?= sanitizeInput((string) ($ex['title'] ?? '')) ?></td>
<td><?= sanitizeInput($nameById[(string) ($ex['assignee_id'] ?? '')] ?? '') ?></td>
<td class="text-end text-nowrap"><?= htmlspecialchars(number_format((float) ($ex['value'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,25 +1,231 @@
<?php
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';
$groceries = readJsonFile('groceries.json');
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
usort($stores, static function ($a, $b) {
$sa = (int) ($a['sort'] ?? 0);
$sb = (int) ($b['sort'] ?? 0);
if ($sa !== $sb) {
return $sa <=> $sb;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$selectedStore = isset($_GET['store']) ? trim((string) $_GET['store']) : '';
if ($selectedStore === '' && count($stores) > 0) {
$selectedStore = (string) ($stores[0]['id'] ?? '');
}
$currentStore = findStoreById($stores, $selectedStore);
$filter = isset($_GET['filter']) ? trim((string) $_GET['filter']) : 'active';
if (!in_array($filter, ['active', 'purchased', 'pending', 'all'], true)) {
$filter = 'active';
}
$allItems = $currentStore ? ($lists['byStore'][$selectedStore] ?? []) : [];
$displayItems = [];
foreach ($allItems as $it) {
if (!is_array($it)) {
continue;
}
$st = (string) ($it['status'] ?? 'active');
if ($filter === 'all') {
$displayItems[] = $it;
} elseif ($filter === 'pending' && $st === 'pending_review') {
$displayItems[] = $it;
} elseif ($filter === 'purchased' && $st === 'purchased') {
$displayItems[] = $it;
} elseif ($filter === 'active' && $st === 'active') {
$displayItems[] = $it;
}
}
$pickerOptions = $currentStore ? groceryCatalogPickerOptions($catalog, $selectedStore) : [];
$canReviewGroceries = $activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified();
?>
<div id="groceries" class="tab-content">
<h2>Grocery List</h2>
<div class="groceries-list">
<?php if (empty($groceries)): ?>
<p>No items in the grocery list yet.</p>
<?php else: ?>
<ul>
<?php foreach ($groceries as $item): ?>
<li>
<span class="item-name"><?php echo sanitizeInput($item['name']); ?></span>
<span class="item-quantity"><?php echo sanitizeInput($item['quantity']); ?></span>
<span class="item-category"><?php echo sanitizeInput($item['category']); ?></span>
</li>
<div id="groceries" class="tab-content" data-store-id="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
<h2 class="mb-3">Grocery list</h2>
<?php if ($activePerson === null): ?>
<div class="alert alert-warning">Choose who is using the hub (top) to edit your list.</div>
<?php endif; ?>
<?php if (count($stores) === 0): ?>
<p class="text-muted">No stores yet. Add one under <strong>Family settings</strong>.</p>
<?php else: ?>
<div class="row g-3">
<div class="col-lg-3">
<nav class="nav flex-lg-column nav-pills grocery-store-nav gap-1" aria-label="Stores">
<?php foreach ($stores as $s): ?>
<?php
$sidRaw = (string) ($s['id'] ?? '');
$sid = htmlspecialchars($sidRaw, ENT_QUOTES, 'UTF-8');
$active = $sidRaw === $selectedStore;
?>
<a class="nav-link <?= $active ? 'active' : '' ?> py-2" href="?tab=groceries&amp;store=<?= $sid ?>&amp;filter=<?= htmlspecialchars($filter, ENT_QUOTES, 'UTF-8') ?>">
<?= sanitizeInput($s['name'] ?? 'Store') ?>
</a>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</nav>
<p class="small text-muted mt-2 d-none d-lg-block">Manage store names under <a href="?tab=settings">Family settings</a>.</p>
</div>
<div class="col-lg-9">
<?php if (!$currentStore): ?>
<p class="alert alert-warning">Pick a store from the list.</p>
<?php else: ?>
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
<span class="text-muted small me-1">Show:</span>
<?php
$filtHref = static function ($f) use ($selectedStore) {
return '?tab=groceries&amp;store=' . rawurlencode($selectedStore) . '&amp;filter=' . rawurlencode($f);
};
?>
<a class="btn btn-sm <?= $filter === 'active' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('active'), ENT_QUOTES, 'UTF-8') ?>">To buy</a>
<a class="btn btn-sm <?= $filter === 'purchased' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('purchased'), ENT_QUOTES, 'UTF-8') ?>">Purchased</a>
<a class="btn btn-sm <?= $filter === 'pending' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('pending'), ENT_QUOTES, 'UTF-8') ?>">Pending review</a>
<a class="btn btn-sm <?= $filter === 'all' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('all'), ENT_QUOTES, 'UTF-8') ?>">All</a>
</div>
<?php if ($activePerson !== null): ?>
<div class="card mb-4">
<div class="card-header">Add item <?= sanitizeInput($currentStore['name'] ?? '') ?></div>
<div class="card-body">
<form id="groceryAddForm" class="row g-3">
<input type="hidden" id="grocery_store_id" value="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
<div class="col-12">
<label class="form-label" for="grocery_catalog_pick">Start from saved item (optional, deduped)</label>
<select class="form-select" id="grocery_catalog_pick">
<option value=""> New item </option>
<?php foreach ($pickerOptions as $opt): ?>
<option
value="<?= htmlspecialchars((string) ($opt['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-name="<?= htmlspecialchars((string) ($opt['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-description="<?= htmlspecialchars((string) ($opt['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-size="<?= htmlspecialchars((string) ($opt['defaultSize'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-image="<?= htmlspecialchars((string) ($opt['defaultImage'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
><?= sanitizeInput($opt['name'] ?? '') ?><?php if (!empty($opt['defaultSize'])): ?> (<?= sanitizeInput((string) $opt['defaultSize']) ?>)<?php endif; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="grocery_name">Name</label>
<input class="form-control" id="grocery_name" required maxlength="200">
</div>
<div class="col-md-6">
<label class="form-label" for="grocery_quantity">Quantity</label>
<input class="form-control" id="grocery_quantity" value="1">
</div>
<div class="col-md-6">
<label class="form-label" for="grocery_size">Size</label>
<input class="form-control" id="grocery_size" placeholder="e.g. 1 gal">
</div>
<div class="col-md-6">
<label class="form-label" for="grocery_price">Price</label>
<input class="form-control" id="grocery_price" placeholder="optional">
</div>
<div class="col-12">
<label class="form-label" for="grocery_description">Description</label>
<textarea class="form-control" id="grocery_description" rows="2"></textarea>
</div>
<div class="col-md-8">
<label class="form-label" for="grocery_image">Image URL</label>
<input class="form-control" id="grocery_image" type="url" placeholder="https://…">
</div>
<div class="col-md-4">
<label class="form-label" for="grocery_recurring">Recurring (days after purchase)</label>
<input type="number" class="form-control" id="grocery_recurring" min="0" value="0" title="0 = not recurring; e.g. 7 = suggest again 7 days after you mark it purchased">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Add to list</button>
</div>
<div class="col-12">
<div class="alert d-none" id="groceryFormFeedback" role="status"></div>
</div>
</form>
</div>
</div>
<?php endif; ?>
<h3 class="h6 text-muted"><?= $filter === 'purchased' ? 'Purchased' : ($filter === 'pending' ? 'Pending review' : ($filter === 'all' ? 'All items' : 'To buy')) ?></h3>
<?php if (count($displayItems) === 0): ?>
<p class="text-muted">No items in this view.</p>
<?php else: ?>
<ul class="list-group grocery-item-list">
<?php foreach ($displayItems as $it): ?>
<?php
$iid = htmlspecialchars((string) ($it['id'] ?? ''), ENT_QUOTES, 'UTF-8');
$isPurchased = ($it['status'] ?? '') === 'purchased';
$isPending = ($it['status'] ?? '') === 'pending_review';
?>
<li class="list-group-item grocery-line">
<div class="d-flex flex-column flex-md-row gap-2 justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-start gap-2">
<?php if (!empty($it['image']) && preg_match('#^https?://#i', (string) $it['image'])): ?>
<img src="<?= sanitizeInput((string) $it['image']) ?>" alt="" class="grocery-line-thumb rounded">
<?php endif; ?>
<div>
<strong><?= sanitizeInput((string) ($it['name'] ?? '')) ?></strong>
<?php if (!empty($it['size'])): ?>
<span class="text-muted small">· <?= sanitizeInput((string) $it['size']) ?></span>
<?php endif; ?>
<?php if ($isPending): ?>
<span class="badge text-bg-warning ms-1">Pending review</span>
<?php endif; ?>
<?php if ((int) ($it['recurringIntervalDays'] ?? 0) > 0): ?>
<span class="badge text-bg-info ms-1">Recurring</span>
<?php endif; ?>
<div class="small text-muted">Qty <?= sanitizeInput((string) ($it['quantity'] ?? '')) ?>
<?php if (!empty($it['price'])): ?> · <?= sanitizeInput((string) $it['price']) ?><?php endif; ?>
</div>
<?php if (!empty($it['description'])): ?>
<div class="small"><?= nl2br(sanitizeInput((string) $it['description'])) ?></div>
<?php endif; ?>
<?php if ($isPurchased && !empty($it['purchasedAt'])): ?>
<div class="small text-muted">Purchased <?= sanitizeInput((string) $it['purchasedAt']) ?></div>
<?php endif; ?>
</div>
</div>
</div>
<div class="btn-group flex-shrink-0">
<?php if ($activePerson !== null && !$isPending): ?>
<?php if (!$isPurchased): ?>
<button type="button" class="btn btn-sm btn-success btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="1">Got it</button>
<?php else: ?>
<button type="button" class="btn btn-sm btn-outline-secondary btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="0">Mark not bought</button>
<?php endif; ?>
<?php endif; ?>
<?php if ($canReviewGroceries && $isPending): ?>
<button type="button" class="btn btn-sm btn-primary btn-grocery-approve" data-item-id="<?= $iid ?>">Approve</button>
<?php endif; ?>
<?php if ($activePerson !== null): ?>
<button type="button" class="btn btn-sm btn-outline-danger btn-grocery-delete" data-item-id="<?= $iid ?>">Remove</button>
<?php endif; ?>
</div>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>

View File

@ -1,25 +1,373 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../includes/persona.php';
require_once __DIR__ . '/../includes/meal_helpers.php';
require_once __DIR__ . '/../includes/grocery_helpers.php';
$meals = readJsonFile('meals.json');
if (!file_exists(DATA_PATH . '/meal_plans.json')) {
writeJsonFile('meal_plans.json', normalizeMealPlan([]));
}
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
migrateLegacyGroceriesIfNeeded();
ensureDefaultGroceryStore();
$stores = normalizeStoresList(readJsonFile('stores.json'));
$detailId = isset($_GET['meal']) ? trim((string) $_GET['meal']) : '';
$detailMeal = $detailId !== '' ? findMealById($meals, $detailId) : null;
$actorId = $activePerson['id'] ?? '';
$canManageMeals = $activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified();
$canEditMeal = static function (array $m) use ($actorId, $canManageMeals): bool {
return $canManageMeals || (($m['author_id'] ?? '') === $actorId && $actorId !== '');
};
$weekStart = $plan['weekStart'];
$slots = $plan['slots'];
$mealTitleById = [];
foreach ($meals as $m) {
if (!empty($m['id'])) {
$mealTitleById[(string) $m['id']] = (string) ($m['title'] ?? '');
}
}
$prefillMeal = null;
$editMealId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
if ($editMealId !== '') {
$prefillMeal = findMealById($meals, $editMealId);
}
$listsPrefill = $prefillMeal['lists'] ?? [['type' => 'checkbox', 'items' => []]];
if (!is_array($listsPrefill) || count($listsPrefill) === 0) {
$listsPrefill = [['type' => 'checkbox', 'items' => []]];
}
$firstList = $listsPrefill[0];
$listType = in_array($firstList['type'] ?? '', ['ordered', 'unordered', 'checkbox'], true) ? $firstList['type'] : 'checkbox';
$listItems = $firstList['items'] ?? [];
if (!is_array($listItems)) {
$listItems = [];
}
?>
<?php if ($detailMeal !== null): ?>
<div id="meals" class="tab-content">
<h2>Meal Planning</h2>
<div class="meals-list">
<?php if (empty($meals)): ?>
<p>No meals planned yet.</p>
<p class="mb-2"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">&larr; Back to meal plan</a></p>
<h2 class="mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
<?php if (!empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image'])): ?>
<img src="<?= sanitizeInput((string) $detailMeal['image']) ?>" class="img-fluid rounded mb-3 meal-hero" alt="">
<?php endif; ?>
<p class="lead"><?= nl2br(sanitizeInput((string) ($detailMeal['description'] ?? ''))) ?></p>
<?php if (count($detailMeal['tags'] ?? []) > 0): ?>
<p><?php foreach ($detailMeal['tags'] as $t): ?><span class="badge text-bg-secondary me-1"><?= sanitizeInput((string) $t) ?></span><?php endforeach; ?></p>
<?php endif; ?>
<h3 class="h5">Directions</h3>
<div class="mb-4"><?= nl2br(sanitizeInput((string) ($detailMeal['directions'] ?? ''))) ?></div>
<?php foreach (($detailMeal['lists'] ?? []) as $block): ?>
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
<?php $t = $block['type'] ?? 'checkbox'; ?>
<?php if ($t === 'ordered'): ?>
<ol><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
<?php elseif ($t === 'unordered'): ?>
<ul><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
<?php else: ?>
<ul>
<?php foreach ($meals as $meal): ?>
<li>
<span class="meal-name"><?php echo sanitizeInput($meal['name']); ?></span>
<span class="meal-date"><?php echo formatDate($meal['date']); ?></span>
<span class="meal-type"><?php echo sanitizeInput($meal['type']); ?></span>
</li>
<?php endforeach; ?>
</ul>
<ul class="list-unstyled"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1"></i><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
<?php endif; ?>
<?php endforeach; ?>
<h3 class="h5">Pantry / staples (ingredients)</h3>
<?php if (count($detailMeal['ingredients'] ?? []) === 0): ?>
<p class="text-muted small">None listed.</p>
<?php else: ?>
<ul class="list-group mb-3">
<?php foreach ($detailMeal['ingredients'] as $ing): ?>
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
<span><?= sanitizeInput((string) $ing) ?></span>
<?php if ($activePerson !== null && count($stores) > 0): ?>
<span class="d-flex flex-wrap gap-1 align-items-center">
<select class="form-select form-select-sm ingredient-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
<?php foreach ($stores as $st): ?>
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($st['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-sm btn-outline-primary btn-ingredient-to-grocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>" data-ingredient="<?= htmlspecialchars((string) $ing, ENT_QUOTES, 'UTF-8') ?>">Add to grocery list</button>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<h3 class="h5">Shopping items</h3>
<?php if (count($detailMeal['items'] ?? []) === 0): ?>
<p class="text-muted small">None listed.</p>
<?php else: ?>
<ul class="list-group mb-3">
<?php foreach ($detailMeal['items'] as $it): ?>
<li class="list-group-item"><?= sanitizeInput((string) ($it['name'] ?? '')) ?>
<?php if (!empty($it['quantity'])): ?> · <?= sanitizeInput((string) $it['quantity']) ?><?php endif; ?>
<?php if (!empty($it['size'])): ?> (<?= sanitizeInput((string) $it['size']) ?>)<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($activePerson !== null && count($detailMeal['items'] ?? []) > 0): ?>
<button type="button" class="btn btn-primary mb-3" id="btnPushMealGrocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Send shopping items to grocery (pending review)</button>
<div class="alert d-none" id="pushMealFeedback" role="status"></div>
<?php endif; ?>
<?php if ($detailMeal && $canEditMeal($detailMeal)): ?>
<p><a class="btn btn-outline-primary" href="?tab=meals&amp;edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a></p>
<?php endif; ?>
</div>
<?php else: ?>
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
<h2 class="mb-2">Meal plan</h2>
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
<?php if ($activePerson === null): ?>
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
<?php endif; ?>
<?php if ($canManageMeals): ?>
<div class="card mb-4">
<div class="card-body row g-2 align-items-end">
<div class="col-md-auto">
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="col-md-auto">
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
</div>
<div class="col-12">
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="table-responsive mb-4">
<table class="table table-bordered meal-grid-table">
<thead>
<tr>
<th></th>
<th>Breakfast</th>
<th>Lunch</th>
<th>Dinner</th>
</tr>
</thead>
<tbody>
<?php for ($d = 0; $d < 7; $d++): ?>
<tr>
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
<?php foreach (mealSlotTypes() as $mt): ?>
<?php
$mid = $slots[(string) $d][$mt] ?? null;
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
?>
<td>
<?php if ($mid): ?>
<a href="?tab=meals&amp;meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
<?php else: ?>
<span class="text-muted"></span>
<?php endif; ?>
<?php if ($activePerson !== null): ?>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
data-day="<?= (int) $d ?>"
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
><?= $mid ? 'Change' : '+' ?></button>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
<h3 class="h5">Meal library</h3>
<?php if ($canManageMeals): ?>
<p><a href="?tab=meals&amp;edit=new" class="btn btn-success btn-sm">New meal</a></p>
<?php else: ?>
<p class="text-muted small">Only a verified Head of household can add meals.</p>
<?php endif; ?>
<?php if (count($meals) === 0): ?>
<p class="text-muted">No meals in the library yet.</p>
<?php else: ?>
<ul class="list-group mb-4">
<?php foreach ($meals as $m): ?>
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<a href="?tab=meals&amp;meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
<?php foreach (($m['tags'] ?? []) as $t): ?>
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
<?php endforeach; ?>
</div>
<span>
<?php if ($canEditMeal($m)): ?>
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&amp;edit=<?= $mid ?>">Edit</a>
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canManageMeals && ($editMealId === 'new' || $prefillMeal !== null)): ?>
<div class="card mb-4 border-primary" id="mealEditorCard">
<div class="card-header"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></div>
<div class="card-body">
<?php if ($editMealId !== 'new' && $prefillMeal === null): ?>
<div class="alert alert-warning">Meal not found.</div>
<?php else: ?>
<form id="mealSaveForm" class="row g-3">
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
<div class="col-md-8">
<label class="form-label" for="meal_title">Title</label>
<input class="form-control" id="meal_title" required value="<?= $prefillMeal ? sanitizeInput($prefillMeal['title'] ?? '') : '' ?>">
</div>
<div class="col-md-4">
<label class="form-label">Tags</label>
<div class="d-flex flex-wrap gap-2">
<?php
$tagSet = $prefillMeal ? array_flip($prefillMeal['tags'] ?? []) : [];
foreach (['breakfast', 'lunch', 'dinner'] as $tg):
?>
<div class="form-check">
<input class="form-check-input meal-tag-cb" type="checkbox" value="<?= $tg ?>" id="tag_<?= $tg ?>" <?= isset($tagSet[$tg]) ? 'checked' : '' ?>>
<label class="form-check-label" for="tag_<?= $tg ?>"><?= ucfirst($tg) ?></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="col-12">
<label class="form-label" for="meal_description">Description</label>
<textarea class="form-control" id="meal_description" rows="2"><?= $prefillMeal ? sanitizeInput($prefillMeal['description'] ?? '') : '' ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label" for="meal_image">Image URL</label>
<input class="form-control" id="meal_image" type="url" value="<?= $prefillMeal ? sanitizeInput($prefillMeal['image'] ?? '') : '' ?>">
</div>
<div class="col-12">
<label class="form-label" for="meal_directions">Directions</label>
<textarea class="form-control" id="meal_directions" rows="4"><?= $prefillMeal ? sanitizeInput($prefillMeal['directions'] ?? '') : '' ?></textarea>
</div>
<div class="col-md-4">
<label class="form-label" for="meal_list_type">Recipe list type</label>
<select class="form-select" id="meal_list_type">
<option value="checkbox" <?= $listType === 'checkbox' ? 'selected' : '' ?>>Checkbox</option>
<option value="unordered" <?= $listType === 'unordered' ? 'selected' : '' ?>>Bullets</option>
<option value="ordered" <?= $listType === 'ordered' ? 'selected' : '' ?>>Numbered</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label" for="meal_list_items">Recipe list (one per line)</label>
<textarea class="form-control" id="meal_list_items" rows="3"><?= sanitizeInput(implode("\n", $listItems)) ?></textarea>
</div>
<div class="col-12">
<label class="form-label" for="meal_ingredients">Ingredients / staples (one per line)</label>
<textarea class="form-control" id="meal_ingredients" rows="3" placeholder="Salt&#10;Olive oil"><?= $prefillMeal ? sanitizeInput(implode("\n", $prefillMeal['ingredients'] ?? [])) : '' ?></textarea>
</div>
<div class="col-12">
<label class="form-label">Shopping items</label>
<p class="small text-muted">Per row: name, optional store, quantity, size, notes store blank uses your first grocery store.</p>
<div id="mealGroceryRows" class="d-flex flex-column gap-2">
<?php
$shopItems = $prefillMeal['items'] ?? [];
if (!is_array($shopItems) || count($shopItems) === 0) {
$shopItems = [['name' => '', 'storeId' => '', 'quantity' => '1', 'size' => '', 'description' => '', 'price' => '', 'image' => '']];
}
foreach ($shopItems as $si):
?>
<div class="row g-2 align-items-end meal-grocery-row">
<div class="col-md-4">
<input class="form-control form-control-sm g-name" placeholder="Name" value="<?= sanitizeInput((string) ($si['name'] ?? '')) ?>">
</div>
<div class="col-md-3">
<select class="form-select form-select-sm g-store">
<option value="">Default store</option>
<?php foreach ($stores as $st): ?>
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" <?= (($si['storeId'] ?? '') === ($st['id'] ?? '')) ? 'selected' : '' ?>><?= sanitizeInput($st['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<input class="form-control form-control-sm g-qty" placeholder="Qty" value="<?= sanitizeInput((string) ($si['quantity'] ?? '1')) ?>">
</div>
<div class="col-md-3">
<input class="form-control form-control-sm g-size" placeholder="Size" value="<?= sanitizeInput((string) ($si['size'] ?? '')) ?>">
</div>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAddGroceryRow">Add shopping row</button>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Save meal</button>
<a href="?tab=meals" class="btn btn-link">Cancel</a>
</div>
<div class="col-12">
<div class="alert d-none" id="mealSaveFeedback" role="status"></div>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="mealSlotModalLabel">Choose a meal</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="slotPickerDay" value="">
<input type="hidden" id="slotPickerType" value="">
<div class="mb-2">
<label class="form-label">Filter by tag (optional)</label>
<select class="form-select" id="slotPickerFilter">
<option value="">All meals</option>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
</select>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="slotPickerPushGrocery" checked>
<label class="form-check-label" for="slotPickerPushGrocery">Add this meals shopping items to grocery (pending review)</label>
</div>
<ul class="list-group" id="slotPickerList"></ul>
<button type="button" class="btn btn-outline-danger btn-sm mt-2" id="slotPickerClear">Clear slot</button>
</div>
</div>
</div>
</div>
<script type="application/json" id="mealsLibraryJson"><?= json_encode(array_map(static function ($m) {
return [
'id' => (string) ($m['id'] ?? ''),
'title' => (string) ($m['title'] ?? ''),
'tags' => array_values($m['tags'] ?? []),
];
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
<?php endif; ?>

225
tabs/settings.php Normal file
View File

@ -0,0 +1,225 @@
<?php
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';
$canManage = count($people) === 0
|| (
$activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified()
);
$permanenceOptions = [
'permanent' => 'Permanent',
'weekly' => 'Weekly',
'biweekly' => 'Bi-weekly',
'monthly' => 'Monthly',
'quarterly' => 'Quarterly',
'yearly' => 'Yearly',
];
?>
<div id="settings" class="tab-content">
<h2 class="mb-3">Family settings</h2>
<p class="text-muted">People and chore currency defaults apply across the whole hub. Heads of household verify with a PIN when switching profiles at the top of the page.</p>
<?php if (count($people) === 0): ?>
<div class="card mb-4 border-primary" id="firstSetupCard">
<div class="card-header bg-primary text-white">First-time setup</div>
<div class="card-body">
<p>Create the first <strong>Head of household</strong> profile so you can manage family members and settings.</p>
<form id="firstHeadForm" class="row g-3">
<div class="col-md-6">
<label class="form-label" for="first_name">Name</label>
<input class="form-control" id="first_name" name="name" required autocomplete="name">
</div>
<div class="col-md-6">
<label class="form-label" for="first_pin">PIN (4+ characters)</label>
<input type="password" class="form-control" id="first_pin" name="pin" required minlength="4" autocomplete="new-password">
</div>
<div class="col-md-4">
<label class="form-label" for="first_favoriteColor">Favorite color</label>
<input type="color" class="form-control form-control-color" id="first_favoriteColor" name="favoriteColor" value="#4a90e2">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-lg">Create profile</button>
</div>
<div class="col-12">
<div class="alert d-none" id="firstHeadFeedback" role="status"></div>
</div>
</form>
</div>
</div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-header">Chore economy</div>
<div class="card-body">
<?php if (!$canManage && count($people) > 0): ?>
<p class="text-muted">Switch to a verified Head of household to edit these settings.</p>
<?php endif; ?>
<form id="familySettingsForm" class="row g-3">
<div class="col-md-4">
<label class="form-label" for="currency_symbol">Currency symbol</label>
<input class="form-control" id="currency_symbol" name="currency_symbol" maxlength="8"
value="<?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-4">
<label class="form-label" for="currency_name">Currency name</label>
<input class="form-control" id="currency_name" name="currency_name"
value="<?= sanitizeInput($familySettings['currency_name'] ?? '') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-4">
<label class="form-label" for="currency_permanence">Balance reset</label>
<select class="form-select" id="currency_permanence" name="currency_permanence" <?= !$canManage ? 'disabled' : '' ?>>
<?php foreach ($permanenceOptions as $val => $label): ?>
<option value="<?= htmlspecialchars($val, ENT_QUOTES, 'UTF-8') ?>" <?= ($familySettings['currency_permanence'] ?? '') === $val ? 'selected' : '' ?>>
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="timezone">Timezone</label>
<input class="form-control" id="timezone" name="timezone"
value="<?= sanitizeInput($familySettings['timezone'] ?? 'UTC') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-6">
<label class="form-label" for="week_starts_on">Week starts on (0 = Sunday)</label>
<input type="number" class="form-control" id="week_starts_on" name="week_starts_on" min="0" max="6"
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<?php if ($canManage): ?>
<div class="col-12">
<button type="submit" class="btn btn-primary">Save settings</button>
</div>
<?php endif; ?>
<div class="col-12">
<div class="alert d-none" id="familySettingsFeedback" role="status"></div>
</div>
</form>
</div>
</div>
<?php
$storesList = normalizeStoresList(readJsonFile('stores.json'));
?>
<div class="card mb-4">
<div class="card-header">Grocery stores</div>
<div class="card-body">
<p class="small text-muted">Each store has its own list in the <strong>Grocery list</strong> tab.</p>
<?php if ($canManage): ?>
<form id="storeAddForm" class="row g-2 align-items-end mb-3">
<div class="col-md-8">
<label class="form-label" for="new_store_name">New store name</label>
<input class="form-control" id="new_store_name" required maxlength="80" placeholder="e.g. Costco">
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary">Add store</button>
</div>
<div class="col-12">
<div class="alert d-none" id="storeAddFeedback" role="status"></div>
</div>
</form>
<?php else: ?>
<p class="text-muted small">Only a verified Head of household can manage stores.</p>
<?php endif; ?>
<?php if (count($storesList) === 0): ?>
<p class="text-muted mb-0">No stores yet. Add one above (or open the Grocery list tab to get a default Home store).</p>
<?php else: ?>
<ul class="list-group">
<?php foreach ($storesList as $st): ?>
<?php $stid = htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
<span><?= sanitizeInput($st['name'] ?? '') ?></span>
<?php if ($canManage): ?>
<span class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary btn-store-rename" data-id="<?= $stid ?>" data-name="<?= htmlspecialchars((string) ($st['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Rename</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-store-delete" data-id="<?= $stid ?>">Delete</button>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php if (count($people) > 0): ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>People</span>
</div>
<div class="card-body">
<?php if ($canManage): ?>
<form id="addPersonForm" class="row g-3 mb-4 pb-3 border-bottom">
<div class="col-md-4">
<label class="form-label" for="add_name">Name</label>
<input class="form-control" id="add_name" required>
</div>
<div class="col-md-4">
<label class="form-label" for="add_role">Role</label>
<select class="form-select" id="add_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-4" id="add_pin_wrap">
<label class="form-label" for="add_pin">PIN (required for Head of household)</label>
<input type="password" class="form-control" id="add_pin" minlength="4" autocomplete="new-password">
</div>
<div class="col-md-4">
<label class="form-label" for="add_favoriteColor">Favorite color</label>
<input type="color" class="form-control form-control-color" id="add_favoriteColor" value="#4a90e2">
</div>
<div class="col-12">
<button type="submit" class="btn btn-success">Add person</button>
</div>
<div class="col-12">
<div class="alert d-none" id="addPersonFeedback" role="status"></div>
</div>
</form>
<?php else: ?>
<p class="text-muted">Only a verified Head of household can add or edit people.</p>
<?php endif; ?>
<div class="table-responsive">
<table class="table align-middle" id="peopleTable">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th class="text-end">Balance</th>
<?php if ($canManage): ?><th></th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($people as $p): ?>
<tr data-person-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
<td><?= sanitizeInput($p['name'] ?? '') ?></td>
<td><?= sanitizeInput($p['role'] ?? '') ?></td>
<td class="text-end"><?= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> <?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?></td>
<?php if ($canManage): ?>
<td class="text-end text-nowrap">
<?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; ?>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-person" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Remove</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>