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.* .env.*
!.env.example !.env.example
!env.example
# IDE files # IDE files
.idea/ .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); 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 { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;

View File

@ -1,25 +1,795 @@
document.addEventListener('DOMContentLoaded', function() { /**
// Tab functionality * Tabs use full page navigation (?tab=) from index.php; active state is set server-side.
const tabs = document.querySelectorAll('.tab'); * Do not programmatically .click() <a class="tab"> links on load that re-follows href and reloads in a tight loop.
const tabContents = document.querySelectorAll('.tab-content'); */
tabs.forEach(tab => { (function ($) {
tab.addEventListener('click', () => { 'use strict';
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.style.display = 'none');
// Add active class to clicked tab var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api';
tab.classList.add('active');
// Show corresponding content function postJson(path, body) {
const targetId = tab.getAttribute('data-target'); return fetch(apiBase + path, {
document.getElementById(targetId).style.display = 'block'; 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 $('#hohPinSubmit').on('click', function () {
if (tabs.length > 0) { var pin = $('#hohPinInput').val() || '';
tabs[0].click(); 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 <?php
require_once __DIR__ . '/env.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 // Load environment variables
Env::load(); Env::load();
@ -31,7 +42,10 @@ define('EXPORT_RETENTION_DAYS', (int)Env::get('EXPORT_RETENTION_DAYS', 30));
$TABS = [ $TABS = [
'chores' => ['title' => 'Chores', 'icon' => 'tasks'], 'chores' => ['title' => 'Chores', 'icon' => 'tasks'],
'groceries' => ['title' => 'Grocery List', 'icon' => 'shopping-cart'], '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 // Load local configuration if exists

View File

@ -7,7 +7,7 @@ class Env {
$envFile = dirname(__DIR__) . '/.env'; $envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) { 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); $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

View File

@ -15,7 +15,10 @@ function exportData($type) {
// Handle export request // Handle export request
if (isset($_GET['type'])) { if (isset($_GET['type'])) {
$type = sanitizeInput($_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)) { if (exportData($type)) {
echo json_encode(['success' => true, 'message' => 'Export successful']); echo json_encode(['success' => true, 'message' => 'Export successful']);
} else { } 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 <?php
function readJsonFile($filename) { function getDataDirectory(): string {
$filepath = __DIR__ . '/../data/' . $filename; return __DIR__ . '/../data';
if (!file_exists($filepath)) {
return [];
}
$content = file_get_contents($filepath);
return json_decode($content, true) ?? [];
} }
function writeJsonFile($filename, $data) { function getDataFilePath(string $filename): string {
$filepath = __DIR__ . '/../data/' . $filename; return getDataDirectory() . '/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT);
return file_put_contents($filepath, $json);
} }
function ensureDataDirectory() { function ensureDataDirectory(): void {
$dataDir = __DIR__ . '/../data'; $dataDir = getDataDirectory();
if (!file_exists($dataDir)) { if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true); mkdir($dataDir, 0755, true);
} }
} }
function readJsonFile(string $filename) {
$filepath = getDataFilePath($filename);
if (!file_exists($filepath)) {
return [];
}
$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

@ -5,19 +5,92 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub</title> <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"> <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"> <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"> <link rel="stylesheet" href="assets/css/style.css">
</head> </head>
<body> <body class="family-hub-body" style="--person-accent: <?= htmlspecialchars($favoriteColor ?? '#4a90e2', ENT_QUOTES, 'UTF-8') ?>;">
<header class="bg-primary text-white p-3"> <header class="app-header text-white p-3 mb-0">
<div class="container"> <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> </div>
</header> </header>
<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"> <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

@ -21,3 +21,15 @@ function ensureExportDirectory() {
mkdir(EXPORT_DESTINATION, 0755, true); 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,25 +2,53 @@
require_once 'config/config.php'; require_once 'config/config.php';
require_once 'includes/db.php'; require_once 'includes/db.php';
require_once 'includes/utils.php'; require_once 'includes/utils.php';
require_once 'includes/persona.php';
require_once 'includes/family_settings.php';
// Determine which tab is active startFamilyHubSession();
$activeTab = isset($_GET['tab']) ? $_GET['tab'] : 'chores';
$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'; include 'includes/header.php';
?> ?>
<div class="container"> <div class="container">
<div class="tabs"> <div class="tabs">
<?php foreach ($TABS as $tabId => $tab): ?> <?php foreach ($TABS as $tabId => $tab): ?>
<a href="?tab=<?= $tabId ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>"> <a href="?tab=<?= htmlspecialchars($tabId, ENT_QUOTES, 'UTF-8') ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>">
<i class="fa fa-<?= $tab['icon'] ?>"></i> <?= $tab['title'] ?> <i class="fa fa-<?= htmlspecialchars($tab['icon'], ENT_QUOTES, 'UTF-8') ?>"></i> <?= htmlspecialchars($tab['title'], ENT_QUOTES, 'UTF-8') ?>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<?php include "tabs/$activeTab.php"; ?> <?php include 'tabs/' . $activeTab . '.php'; ?>
</div> </div>
</div> </div>

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>

117
readme.md
View File

@ -1,21 +1,80 @@
# Family Hub # 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 ## Features
- Tabbed interface for different family needs - Tabbed interface for chores, groceries, meals, shared Google Calendar embed, currency, and family settings (profiles, stores, preferences)
- JSON-based data storage - JSON-based data storage under `data/`
- Daily automated exports - Daily automated exports (`scripts/daily_export.php` and `export.php?type=…`)
- Mobile-friendly interface - Mobile-friendly interface
## Setup ## Setup
1. Clone this repository to your web server 1. Clone this repository to your web server.
2. Ensure proper permissions on data directory 2. **Configure the environment**
3. Set up the daily cron job for exports - 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`**.
4. Access the hub at http://your-local-ip/familyHub/ - 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 ## Directory Structure
familyHub/ familyHub/
├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, …)
├── assets/ # Static assets ├── assets/ # Static assets
│ ├── css/ # CSS files │ ├── css/ # CSS files
│ │ └── style.css │ │ └── style.css
@ -23,25 +82,39 @@ familyHub/
│ │ └── main.js │ │ └── main.js
│ └── img/ # Images │ └── img/ # Images
├── config/ # Configuration files ├── config/ # Configuration files
│ └── config.php │ ├── config.php
│ └── env.php # Loads .env
├── data/ # JSON data storage (not tracked in git) ├── data/ # JSON data storage (not tracked in git)
│ ├── chores.json │ ├── chores.json # Chores, assignments, submissions, reviews
│ ├── groceries.json │ ├── people.json # Profiles, roles, PIN hashes, currency balances
│ └── meals.json │ ├── family_settings.json # Currency labels, timezone, week start, etc.
├── includes/ # PHP includes/components │ ├── 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 │ ├── header.php
│ ├── footer.php │ ├── footer.php
│ ├── db.php # JSON file handling functions │ ├── db.php # JSON file I/O (flock)
│ └── utils.php # Utility functions │ └── utils.php # Shared helpers
├── exports/ # Temporary location for exports (not tracked in git) ├── exports/ # Export output (not tracked in git)
├── scripts/ # Scripts for cron jobs ├── scripts/ # Cron / CLI helpers
│ └── daily_export.php │ └── daily_export.php
├── tabs/ # Tab-specific functionality ├── tabs/ # Tab views included by `index.php`
│ ├── chores.php │ ├── chores.php
│ ├── groceries.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 ├── .gitignore
├── .cursor.json # Cursor editor configuration ├── .cursor/ # Cursor editor configuration and rules
├── README.md ├── readme.md
├── index.php # Main entry point ├── 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'; require_once __DIR__ . '/../config/config.php';
// Export all data types // Export all data types
$types = ['chores', 'groceries', 'meals']; $types = ['chores', 'groceries', 'meals', 'meal_plans', 'expenses', 'stores', 'grocery_lists', 'grocery_catalog'];
$results = []; $results = [];
foreach ($types as $type) { 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 <?php
require_once __DIR__ . '/../includes/db.php'; require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.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"> <div id="chores" class="tab-content">
<h2>Chores</h2> <h2 class="mb-2">Chores</h2>
<div class="chores-list">
<?php if (empty($chores)): ?> <?php if ($activePerson !== null): ?>
<p>No chores added yet.</p> <div class="alert alert-info small mb-3 chore-howto">
<?php else: ?> <strong>How to finish a chore:</strong> The checklist bullets are reminders only (not clickable).
<ul> If this chore is assigned to you, use <strong>Submit for approval</strong> on the card when the work is done.
<?php foreach ($chores as $chore): ?> A Head of household then <strong>approves</strong> it so the reward is added to balances.
<li> <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="chore-name"><?php echo sanitizeInput($chore['name']); ?></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>
<span class="chore-assignee"><?php echo sanitizeInput($chore['assignee']); ?></span> </div>
<span class="chore-due-date"><?php echo formatDate($chore['due_date']); ?></span> <?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> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </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> </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 <?php
require_once __DIR__ . '/../includes/db.php'; require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.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"> <div id="groceries" class="tab-content" data-store-id="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
<h2>Grocery List</h2> <h2 class="mb-3">Grocery list</h2>
<div class="groceries-list">
<?php if (empty($groceries)): ?> <?php if ($activePerson === null): ?>
<p>No items in the grocery list yet.</p> <div class="alert alert-warning">Choose who is using the hub (top) to edit your list.</div>
<?php else: ?> <?php endif; ?>
<ul>
<?php foreach ($groceries as $item): ?> <?php if (count($stores) === 0): ?>
<li> <p class="text-muted">No stores yet. Add one under <strong>Family settings</strong>.</p>
<span class="item-name"><?php echo sanitizeInput($item['name']); ?></span> <?php else: ?>
<span class="item-quantity"><?php echo sanitizeInput($item['quantity']); ?></span>
<span class="item-category"><?php echo sanitizeInput($item['category']); ?></span> <div class="row g-3">
</li> <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; ?> <?php endforeach; ?>
</ul> </nav>
<?php endif; ?> <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> </div>

View File

@ -1,25 +1,373 @@
<?php <?php
require_once __DIR__ . '/../includes/db.php'; require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.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"> <div id="meals" class="tab-content">
<h2>Meal Planning</h2> <p class="mb-2"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">&larr; Back to meal plan</a></p>
<div class="meals-list"> <h2 class="mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
<?php if (empty($meals)): ?> <?php if (!empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image'])): ?>
<p>No meals planned yet.</p> <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: ?> <?php else: ?>
<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 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>
<?php endif; ?> <?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>
<?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>
</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>