From 6353f9c9b4479612f38973aba0a2c226b7ee9238 Mon Sep 17 00:00:00 2001 From: Louis Whittington Date: Mon, 30 Mar 2026 14:53:13 -0500 Subject: [PATCH] First pass of features --- .cursor/rules/env-example-and-readme.mdc | 23 + .gitignore | 1 + AGENTS.md | 60 ++ api/chore_delete.php | 40 ++ api/chore_review.php | 87 +++ api/chore_save.php | 97 +++ api/chore_submit.php | 59 ++ api/expense_create.php | 88 +++ api/family_settings_save.php | 48 ++ api/grocery_item_create.php | 78 +++ api/grocery_item_delete.php | 39 ++ api/grocery_item_purchase.php | 70 ++ api/grocery_item_review.php | 50 ++ api/meal_delete.php | 39 ++ api/meal_ingredient_to_grocery.php | 59 ++ api/meal_plan_set_slot.php | 68 ++ api/meal_plan_set_week.php | 31 + api/meal_push_grocery.php | 34 + api/meal_save.php | 69 ++ api/people_create.php | 71 ++ api/people_create_first.php | 59 ++ api/people_delete.php | 50 ++ api/people_reset_pin.php | 48 ++ api/people_update.php | 96 +++ api/store_create.php | 39 ++ api/store_delete.php | 61 ++ api/store_update.php | 43 ++ api/switch_person.php | 35 + assets/css/style.css | 45 ++ assets/js/main.js | 808 ++++++++++++++++++++++- config/config.php | 16 +- config/env.php | 2 +- export.php | 5 +- includes/api_bootstrap.php | 42 ++ includes/chore_helpers.php | 198 ++++++ includes/db.php | 76 ++- includes/expense_helpers.php | 18 + includes/family_settings.php | 31 + includes/grocery_helpers.php | 395 +++++++++++ includes/header.php | 93 ++- includes/meal_helpers.php | 298 +++++++++ includes/persona.php | 87 +++ includes/utils.php | 14 +- index.php | 44 +- install.php | 227 +++++++ readme.md | 119 +++- scripts/daily_export.php | 2 +- scripts/grocery_recurring.php | 71 ++ tabs/calendar.php | 67 ++ tabs/chores.php | 316 ++++++++- tabs/currency.php | 178 +++++ tabs/groceries.php | 240 ++++++- tabs/meals.php | 378 ++++++++++- tabs/settings.php | 225 +++++++ 54 files changed, 5409 insertions(+), 128 deletions(-) create mode 100644 .cursor/rules/env-example-and-readme.mdc create mode 100644 AGENTS.md create mode 100644 api/chore_delete.php create mode 100644 api/chore_review.php create mode 100644 api/chore_save.php create mode 100644 api/chore_submit.php create mode 100644 api/expense_create.php create mode 100644 api/family_settings_save.php create mode 100644 api/grocery_item_create.php create mode 100644 api/grocery_item_delete.php create mode 100644 api/grocery_item_purchase.php create mode 100644 api/grocery_item_review.php create mode 100644 api/meal_delete.php create mode 100644 api/meal_ingredient_to_grocery.php create mode 100644 api/meal_plan_set_slot.php create mode 100644 api/meal_plan_set_week.php create mode 100644 api/meal_push_grocery.php create mode 100644 api/meal_save.php create mode 100644 api/people_create.php create mode 100644 api/people_create_first.php create mode 100644 api/people_delete.php create mode 100644 api/people_reset_pin.php create mode 100644 api/people_update.php create mode 100644 api/store_create.php create mode 100644 api/store_delete.php create mode 100644 api/store_update.php create mode 100644 api/switch_person.php create mode 100644 includes/api_bootstrap.php create mode 100644 includes/chore_helpers.php create mode 100644 includes/expense_helpers.php create mode 100644 includes/family_settings.php create mode 100644 includes/grocery_helpers.php create mode 100644 includes/meal_helpers.php create mode 100644 includes/persona.php create mode 100644 install.php create mode 100644 scripts/grocery_recurring.php create mode 100644 tabs/calendar.php create mode 100644 tabs/currency.php create mode 100644 tabs/settings.php diff --git a/.cursor/rules/env-example-and-readme.mdc b/.cursor/rules/env-example-and-readme.mdc new file mode 100644 index 0000000..c5cf71a --- /dev/null +++ b/.cursor/rules/env-example-and-readme.mdc @@ -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. diff --git a/.gitignore b/.gitignore index a08eb43..4e8fa31 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .env .env.* !.env.example +!env.example # IDE files .idea/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cba105e --- /dev/null +++ b/AGENTS.md @@ -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/`. diff --git a/api/chore_delete.php b/api/chore_delete.php new file mode 100644 index 0000000..ab4a601 --- /dev/null +++ b/api/chore_delete.php @@ -0,0 +1,40 @@ + 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]); diff --git a/api/chore_review.php b/api/chore_review.php new file mode 100644 index 0000000..f4fc6cd --- /dev/null +++ b/api/chore_review.php @@ -0,0 +1,87 @@ + 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]); diff --git a/api/chore_save.php b/api/chore_save.php new file mode 100644 index 0000000..68f28cd --- /dev/null +++ b/api/chore_save.php @@ -0,0 +1,97 @@ + 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]); diff --git a/api/chore_submit.php b/api/chore_submit.php new file mode 100644 index 0000000..7fc17b0 --- /dev/null +++ b/api/chore_submit.php @@ -0,0 +1,59 @@ + 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]); diff --git a/api/expense_create.php b/api/expense_create.php new file mode 100644 index 0000000..4e8a658 --- /dev/null +++ b/api/expense_create.php @@ -0,0 +1,88 @@ + 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']]); diff --git a/api/family_settings_save.php b/api/family_settings_save.php new file mode 100644 index 0000000..acabafe --- /dev/null +++ b/api/family_settings_save.php @@ -0,0 +1,48 @@ + 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 0–6'], 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]); diff --git a/api/grocery_item_create.php b/api/grocery_item_create.php new file mode 100644 index 0000000..c3c0d75 --- /dev/null +++ b/api/grocery_item_create.php @@ -0,0 +1,78 @@ + 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']]); diff --git a/api/grocery_item_delete.php b/api/grocery_item_delete.php new file mode 100644 index 0000000..bba4ccf --- /dev/null +++ b/api/grocery_item_delete.php @@ -0,0 +1,39 @@ + 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]); diff --git a/api/grocery_item_purchase.php b/api/grocery_item_purchase.php new file mode 100644 index 0000000..4049c07 --- /dev/null +++ b/api/grocery_item_purchase.php @@ -0,0 +1,70 @@ + 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]); diff --git a/api/grocery_item_review.php b/api/grocery_item_review.php new file mode 100644 index 0000000..345aa02 --- /dev/null +++ b/api/grocery_item_review.php @@ -0,0 +1,50 @@ + 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]); diff --git a/api/meal_delete.php b/api/meal_delete.php new file mode 100644 index 0000000..e779b82 --- /dev/null +++ b/api/meal_delete.php @@ -0,0 +1,39 @@ + 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]); diff --git a/api/meal_ingredient_to_grocery.php b/api/meal_ingredient_to_grocery.php new file mode 100644 index 0000000..62a0803 --- /dev/null +++ b/api/meal_ingredient_to_grocery.php @@ -0,0 +1,59 @@ + 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']]); diff --git a/api/meal_plan_set_slot.php b/api/meal_plan_set_slot.php new file mode 100644 index 0000000..cf6808f --- /dev/null +++ b/api/meal_plan_set_slot.php @@ -0,0 +1,68 @@ + 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 0–6 (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]); diff --git a/api/meal_plan_set_week.php b/api/meal_plan_set_week.php new file mode 100644 index 0000000..1572595 --- /dev/null +++ b/api/meal_plan_set_week.php @@ -0,0 +1,31 @@ + 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]); diff --git a/api/meal_push_grocery.php b/api/meal_push_grocery.php new file mode 100644 index 0000000..0eb2181 --- /dev/null +++ b/api/meal_push_grocery.php @@ -0,0 +1,34 @@ + 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]); diff --git a/api/meal_save.php b/api/meal_save.php new file mode 100644 index 0000000..546481e --- /dev/null +++ b/api/meal_save.php @@ -0,0 +1,69 @@ + 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]); diff --git a/api/people_create.php b/api/people_create.php new file mode 100644 index 0000000..242eee5 --- /dev/null +++ b/api/people_create.php @@ -0,0 +1,71 @@ + 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]); diff --git a/api/people_create_first.php b/api/people_create_first.php new file mode 100644 index 0000000..dbe5a84 --- /dev/null +++ b/api/people_create_first.php @@ -0,0 +1,59 @@ + 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]); diff --git a/api/people_delete.php b/api/people_delete.php new file mode 100644 index 0000000..243d6f9 --- /dev/null +++ b/api/people_delete.php @@ -0,0 +1,50 @@ + 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]); diff --git a/api/people_reset_pin.php b/api/people_reset_pin.php new file mode 100644 index 0000000..b55187a --- /dev/null +++ b/api/people_reset_pin.php @@ -0,0 +1,48 @@ + 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]); diff --git a/api/people_update.php b/api/people_update.php new file mode 100644 index 0000000..788e2f3 --- /dev/null +++ b/api/people_update.php @@ -0,0 +1,96 @@ + 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]); diff --git a/api/store_create.php b/api/store_create.php new file mode 100644 index 0000000..534af0b --- /dev/null +++ b/api/store_create.php @@ -0,0 +1,39 @@ + 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]]); diff --git a/api/store_delete.php b/api/store_delete.php new file mode 100644 index 0000000..1adbbe5 --- /dev/null +++ b/api/store_delete.php @@ -0,0 +1,61 @@ + 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]); diff --git a/api/store_update.php b/api/store_update.php new file mode 100644 index 0000000..b1fdf2a --- /dev/null +++ b/api/store_update.php @@ -0,0 +1,43 @@ + 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]); diff --git a/api/switch_person.php b/api/switch_person.php new file mode 100644 index 0000000..7277eff --- /dev/null +++ b/api/switch_person.php @@ -0,0 +1,35 @@ + 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]); diff --git a/assets/css/style.css b/assets/css/style.css index 588c095..b9872c0 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -14,6 +14,51 @@ body { color: var(--text-color); } +.app-header { + background: linear-gradient(135deg, var(--person-accent, #4a90e2) 0%, #2c5282 100%); +} + +.persona-chip { + min-height: 2.75rem; + touch-action: manipulation; +} + +.persona-chip.active { + font-weight: 600; + box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.35); +} + +.family-hub-body .card { + border-color: var(--border-color); +} + +.chore-thumb { + max-height: 180px; + object-fit: cover; +} + +.meal-hero { + max-height: 320px; + width: 100%; + object-fit: cover; +} + +.chore-card .card-body { + display: flex; + flex-direction: column; +} + +.grocery-store-nav .nav-link { + text-align: left; + touch-action: manipulation; +} + +.grocery-line-thumb { + width: 56px; + height: 56px; + object-fit: cover; +} + .container { max-width: 1200px; margin: 0 auto; diff --git a/assets/js/main.js b/assets/js/main.js index f97ac9d..822df47 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,25 +1,795 @@ -document.addEventListener('DOMContentLoaded', function() { - // Tab functionality - const tabs = document.querySelectorAll('.tab'); - const tabContents = document.querySelectorAll('.tab-content'); +/** + * Tabs use full page navigation (?tab=) from index.php; active state is set server-side. + * Do not programmatically .click() links on load — that re-follows href and reloads in a tight loop. + */ - tabs.forEach(tab => { - tab.addEventListener('click', () => { - // Remove active class from all tabs - tabs.forEach(t => t.classList.remove('active')); - tabContents.forEach(content => content.style.display = 'none'); +(function ($) { + 'use strict'; - // Add active class to clicked tab - tab.classList.add('active'); - - // Show corresponding content - const targetId = tab.getAttribute('data-target'); - document.getElementById(targetId).style.display = 'block'; + var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api'; + + function postJson(path, body) { + return fetch(apiBase + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body || {}) + }).then(function (res) { + return res.json().then(function (data) { + if (!res.ok) { + var err = new Error(data.error || res.statusText || 'Request failed'); + err.payload = data; + err.status = res.status; + throw err; + } + return data; + }); + }); + } + + function showAlert($el, kind, message) { + if (!$el || !$el.length) { + return; + } + $el.removeClass('d-none alert-success alert-danger alert-warning'); + $el.addClass('alert-' + kind); + $el.text(message); + } + + var pendingPersonId = null; + var pinModal = null; + + function switchPerson(personId, pin) { + return postJson('/switch_person.php', { personId: personId, pin: pin || '' }); + } + + function bindPersonaSwitcher() { + $(document).on('click', '.persona-chip', function () { + var $btn = $(this); + var id = $btn.data('person-id'); + var needsPin = String($btn.data('needs-pin')) === '1'; + var name = $btn.data('person-name') || 'this profile'; + + if (!id) { + return; + } + + if (needsPin) { + pendingPersonId = id; + $('#hohPinModalPrompt').text('Enter PIN for ' + name + '.'); + $('#hohPinInput').val(''); + $('#hohPinError').addClass('d-none').text(''); + if (!pinModal && typeof bootstrap !== 'undefined') { + pinModal = new bootstrap.Modal(document.getElementById('hohPinModal')); + } + if (pinModal) { + pinModal.show(); + } + setTimeout(function () { + document.getElementById('hohPinInput').focus(); + }, 200); + return; + } + + switchPerson(id, '') + .then(function () { + window.location.reload(); + }) + .catch(function (err) { + window.alert(err.message || 'Could not switch profile'); }); }); - // Initialize first tab as active - if (tabs.length > 0) { - tabs[0].click(); + $('#hohPinSubmit').on('click', function () { + var pin = $('#hohPinInput').val() || ''; + if (!pendingPersonId) { + return; + } + $('#hohPinError').addClass('d-none').text(''); + switchPerson(pendingPersonId, pin) + .then(function () { + if (pinModal) { + pinModal.hide(); + } + pendingPersonId = null; + window.location.reload(); + }) + .catch(function (err) { + $('#hohPinError').removeClass('d-none').text(err.message || 'PIN incorrect'); + }); + }); + } + + function bindFirstHeadForm() { + var $form = $('#firstHeadForm'); + if (!$form.length) { + return; } -}); \ No newline at end of file + $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 = $('
  • ') + .text(m.title || m.id) + .attr('data-meal-id', m.id); + $ul.append($li); + }); + if ($ul.children().length === 0) { + $ul.append('
  • No meals match this filter.
  • '); + } + } + + $(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); diff --git a/config/config.php b/config/config.php index 6d3cd42..c52e412 100644 --- a/config/config.php +++ b/config/config.php @@ -1,6 +1,17 @@ ['title' => 'Chores', 'icon' => 'tasks'], 'groceries' => ['title' => 'Grocery List', 'icon' => 'shopping-cart'], - 'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils'] + 'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils'], + 'calendar' => ['title' => 'Calendar', 'icon' => 'calendar-days'], + 'currency' => ['title' => 'Currency', 'icon' => 'coins'], + 'settings' => ['title' => 'Family settings', 'icon' => 'cog'], ]; // Load local configuration if exists diff --git a/config/env.php b/config/env.php index d937892..230d559 100644 --- a/config/env.php +++ b/config/env.php @@ -7,7 +7,7 @@ class Env { $envFile = dirname(__DIR__) . '/.env'; if (!file_exists($envFile)) { - throw new Exception('.env file not found. Please create one based on .env.example'); + throw new Exception('.env file not found. Please create one based on env.example or run install.php'); } $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); diff --git a/export.php b/export.php index f3567dc..206a08b 100644 --- a/export.php +++ b/export.php @@ -15,7 +15,10 @@ function exportData($type) { // Handle export request if (isset($_GET['type'])) { $type = sanitizeInput($_GET['type']); - if (in_array($type, ['chores', 'groceries', 'meals'])) { + if (in_array($type, [ + 'chores', 'groceries', 'meals', 'meal_plans', 'expenses', + 'stores', 'grocery_lists', 'grocery_catalog', + ], true)) { if (exportData($type)) { echo json_encode(['success' => true, 'message' => 'Export successful']); } else { diff --git a/includes/api_bootstrap.php b/includes/api_bootstrap.php new file mode 100644 index 0000000..032fa79 --- /dev/null +++ b/includes/api_bootstrap.php @@ -0,0 +1,42 @@ + + */ +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> $people + * @return array + */ +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; +} diff --git a/includes/chore_helpers.php b/includes/chore_helpers.php new file mode 100644 index 0000000..de8e996 --- /dev/null +++ b/includes/chore_helpers.php @@ -0,0 +1,198 @@ +> + */ +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> $people + * @return array + */ +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 $c + * @param array> $people + * @return array + */ +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}> + */ +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> $chores + * @return array> + */ +function migrateAllChores(array $chores, array $people): array { + $out = []; + foreach ($chores as $c) { + $out[] = migrateLegacyChoreRow($c, $people); + } + return $out; +} + +/** + * @param array> $chores + */ +function findChoreById(array $chores, string $id): ?array { + foreach ($chores as $c) { + if (($c['id'] ?? '') === $id) { + return $c; + } + } + return null; +} + +/** + * @param array> $chores + */ +function findChoreIndexById(array $chores, string $id): ?int { + foreach ($chores as $i => $c) { + if (($c['id'] ?? '') === $id) { + return $i; + } + } + return null; +} + +/** + * @param array $assigneeIds + * @param array> $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; +} diff --git a/includes/db.php b/includes/db.php index 823a257..857b776 100644 --- a/includes/db.php +++ b/includes/db.php @@ -1,23 +1,65 @@ > + */ +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; +} diff --git a/includes/family_settings.php b/includes/family_settings.php new file mode 100644 index 0000000..51746c8 --- /dev/null +++ b/includes/family_settings.php @@ -0,0 +1,31 @@ + '★', + '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'; +} diff --git a/includes/grocery_helpers.php b/includes/grocery_helpers.php new file mode 100644 index 0000000..71c67c5 --- /dev/null +++ b/includes/grocery_helpers.php @@ -0,0 +1,395 @@ +> + */ +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> + */ +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>>} + */ +function defaultGroceryListsShape(): array { + return ['byStore' => []]; +} + +/** + * @param mixed $raw + * @return array{byStore: array>>} + */ +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> $stores + */ +function findStoreById(array $stores, string $id): ?array { + foreach ($stores as $s) { + if (($s['id'] ?? '') === $id) { + return $s; + } + } + return null; +} + +/** + * @param array> $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> $catalog + * @return array> + */ +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>, lists: array{byStore: array>>}, 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> $stores + * @return array{ok: bool, item?: array, 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> $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 $item + * @return array + */ +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>>} $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); +} diff --git a/includes/header.php b/includes/header.php index df098cc..e262f29 100644 --- a/includes/header.php +++ b/includes/header.php @@ -4,20 +4,93 @@ Family Hub - - + - - - - - -
    + +
    -

    Family Hub

    +
    +

    Family Hub

    +
    + + 0): ?> +
    + Balance
    + + +
    + + +
    +
    -
    \ No newline at end of file + + + + +
    diff --git a/includes/meal_helpers.php b/includes/meal_helpers.php new file mode 100644 index 0000000..50ecb00 --- /dev/null +++ b/includes/meal_helpers.php @@ -0,0 +1,298 @@ + */ +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> + */ +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>} + */ +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> + */ +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 $m + * @return array + */ +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> $meals + */ +function findMealById(array $meals, string $id): ?array { + foreach ($meals as $m) { + if (($m['id'] ?? '') === $id) { + return $m; + } + } + return null; +} + +/** + * @param array> $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> $meals + * @return array> + */ +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> $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); +} diff --git a/includes/persona.php b/includes/persona.php new file mode 100644 index 0000000..b413551 --- /dev/null +++ b/includes/persona.php @@ -0,0 +1,87 @@ + false, 'error' => 'Head of household verification required.']); + exit; + } +} + +/** + * @param mixed $raw + * @return array> + */ +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; +} diff --git a/includes/utils.php b/includes/utils.php index 512fee0..6937b50 100644 --- a/includes/utils.php +++ b/includes/utils.php @@ -20,4 +20,16 @@ function ensureExportDirectory() { if (!file_exists(EXPORT_DESTINATION)) { mkdir(EXPORT_DESTINATION, 0755, true); } -} \ No newline at end of file +} + +/** + * 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'; +} diff --git a/index.php b/index.php index b63e932..e6756ab 100644 --- a/index.php +++ b/index.php @@ -2,26 +2,54 @@ require_once 'config/config.php'; require_once 'includes/db.php'; require_once 'includes/utils.php'; +require_once 'includes/persona.php'; +require_once 'includes/family_settings.php'; -// Determine which tab is active -$activeTab = isset($_GET['tab']) ? $_GET['tab'] : 'chores'; +startFamilyHubSession(); + +$people = normalizePeopleList(readJsonFile('people.json')); +if (getActivePersonId() !== null && getActivePerson($people) === null) { + clearPersonaSession(); +} + +$activePerson = getActivePerson($people); +$familySettings = loadFamilySettings(); + +if (isset($TABS['currency'])) { + $TABS['currency']['title'] = currencyTabLabel($familySettings); +} + +$activeTab = isset($_GET['tab']) ? (string) $_GET['tab'] : 'chores'; +if (!isset($TABS[$activeTab])) { + $activeTab = 'chores'; +} + +$familyHubApiBase = familyHubWebApiBase(); + +$favoriteColor = '#4a90e2'; +if ( + $activePerson !== null + && !empty($activePerson['favoriteColor']) + && preg_match('/^#[0-9A-Fa-f]{6}$/', (string) $activePerson['favoriteColor']) +) { + $favoriteColor = (string) $activePerson['favoriteColor']; +} -// Include header include 'includes/header.php'; ?> - \ No newline at end of file + diff --git a/install.php b/install.php new file mode 100644 index 0000000..68d2a6d --- /dev/null +++ b/install.php @@ -0,0 +1,227 @@ += 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 + */ +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'); + ?> + + + + + + Family Hub — Setup + + + +
    +
    +

    Family Hub

    +

    This application is already configured (.env exists).

    + Go to Family Hub +
    +
    + + + + + + + + + Family Hub — Setup error + + + +
    +
    +

    Setup cannot continue

    +

    The template file env.example is missing or not readable. Restore it from the repository.

    +
    +
    + + + + + + + + + Family Hub — Environment setup + + + + +
    +
    +
    +

    Environment setup

    +

    Values are generated from env.example. Fill in your credentials and save.

    +
    + + +
    Could not write .env. Check that the app directory is writable by the web server.
    + + +
    +
    + +

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

    Calendar

    + +

    Connect a shared family calendar by setting GOOGLE_CALENDAR_EMBED_CODE (embed URL or full <iframe> from Google Calendar) or GOOGLE_CALENDAR_ID in .env. See Google Calendar → calendar menu → Settings and sharingIntegrate calendar.

    + + +

    Showing calendar from GOOGLE_CALENDAR_ID. For more control (view, height), paste the embed URL or iframe into GOOGLE_CALENDAR_EMBED_CODE instead.

    + +
    + +
    + +
    diff --git a/tabs/chores.php b/tabs/chores.php index 07aba2d..90df7a9 100644 --- a/tabs/chores.php +++ b/tabs/chores.php @@ -1,25 +1,313 @@ '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; + } + } + } +} ?>
    -

    Chores

    -
    - -

    No chores added yet.

    - -
      - -
    • - - - +

      Chores

      + + +
      + How to finish a chore: The checklist bullets are reminders only (not clickable). + If this chore is assigned to you, use Submit for approval on the card when the work is done. + A Head of household then approves it so the reward is added to balances. + Use the person chips at the top to switch to the right family member if you don’t see that button. + New chores: Only a verified Head of household can add them (switch profile and enter PIN if needed). +
      + + + +
      Choose who is using the hub (top of the page) to work with chores.
      + + + 0): ?> +
      +
      Waiting for your approval
      +
        + + +
      • +
        + + — submitted by +
        · assignees: + +
        +
        +
        + + +
      - +
      + + + +
      That chore was not found. Back to chores
      + + + +
      +
      + + + Cancel edit + +
      +
      +
      + + +
      + + +
      +
      + + +
      + +
      + + +
      + +
      + + +
      +
      + + +
      + +
      + + +
      +
      + + +
      + +
      + +
      + + +
      + > + +
      + +
      +
      + +
      + + +
      +
      + +

      Shown on the card as a numbered, bullet, or checkbox-style list. Assignees tick these off in real life; finishing the chore is the Submit for approval button on the card.

      + +
      + +
      + + + + +
      +
      +
      +
      +
      +
      -
    \ No newline at end of file + +
    You can’t edit this chore. Only the person who created it or a verified Head of household can.
    + +

    Switch to a verified Head of household (top of the page) to add new chores.

    + + +

    Active chores

    + +

    No active chores.

    + +
    + + +
    +
    + + + +
    +

    +

    +

    + Reward: + + · Due + +

    +

    + + + Waiting for approval + +

    + +

    Steps (reference — use the button below when you’re done)

    + + + + + +
    + +
    + +
    + + +
    + + + + + To submit when done: switch to at the top. + + + Edit + +
    +
    +
    +
    + +
    + + + 0): ?> +
    + Completed chores () +
      + +
    • + +
    +
    + +
    diff --git a/tabs/currency.php b/tabs/currency.php new file mode 100644 index 0000000..77c91df --- /dev/null +++ b/tabs/currency.php @@ -0,0 +1,178 @@ + $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'); +?> + +
    +

    +

    Leaderboard and expenses use the family currency from Family settings.

    + +
    +
    +
    +
    + Leaderboard +
    +
    + +

    Add people in Family settings to see balances.

    + +
    + + + + + + + + + + $p): ?> + + + + + + + + +
    #NameBalance
    You
    +
    + +
    +
    +
    + +
    + +
    +
    + Record expense + +
    +
    +
    +

    Deducts from one person’s balance (e.g. spent allowance). Requires enough balance.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + 0): ?> +

    Switch to a verified Head of household to record expenses.

    + + +
    +
    Recent expenses
    +
    + +

    No expenses recorded yet.

    + +
    + + + + + + + + + + + + + + + + + + + +
    DateTitleToAmount
    +
    + +
    +
    +
    +
    +
    diff --git a/tabs/groceries.php b/tabs/groceries.php index 266637f..8c69690 100644 --- a/tabs/groceries.php +++ b/tabs/groceries.php @@ -1,25 +1,231 @@ $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(); ?> -
    -

    Grocery List

    -
    - -

    No items in the grocery list yet.

    - -
      - -
    • - - - -
    • +
      +

      Grocery list

      + + +
      Choose who is using the hub (top) to edit your list.
      + + + +

      No stores yet. Add one under Family settings.

      + + +
      +
      +
    - + +

    Manage store names under Family settings.

    +
    + +
    + +

    Pick a store from the list.

    + + +
    + Show: + + To buy + Purchased + Pending review + All +
    + + +
    +
    Add item —
    +
    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + +

    + +

    No items in this view.

    + +
      + + +
    • +
      +
      +
      + + + +
      + + + · + + + Pending review + + 0): ?> + Recurring + +
      Qty + · +
      + +
      + + +
      Purchased
      + +
      +
      +
      +
      + + + + + + + + + + + + + +
      +
      +
    • + +
    + + + +
    - \ No newline at end of file + + diff --git a/tabs/meals.php b/tabs/meals.php index 4c5ba48..bebd351 100644 --- a/tabs/meals.php +++ b/tabs/meals.php @@ -1,25 +1,373 @@ '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 = []; +} ?> +
    -

    Meal Planning

    -
    - -

    No meals planned yet.

    +

    ← Back to meal plan

    +

    + + + +

    + + 0): ?> +

    + + +

    Directions

    +
    + + + + + +
    + +
    -
      - -
    • - - - -
    • - -
    +
    + + +

    Pantry / staples (ingredients)

    + +

    None listed.

    + +
      + +
    • + + 0): ?> + + + + + +
    • + +
    + + +

    Shopping items

    + +

    None listed.

    + +
      + +
    • + · + () +
    • + +
    + + + 0): ?> + +
    + + + +

    Edit meal

    + +
    + + + +
    +

    Meal plan

    +

    Week starts on (day 0). Assign meals to each slot; shopping items go to the grocery list as pending review.

    + + +
    Choose who is using the hub to use the meal planner.
    + + + +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    -
    \ No newline at end of file + + +
    + + + + + + + + + + + + + + + + + + + + +
    BreakfastLunchDinner
    + + + + + + + + +
    +
    + +

    Meal library

    + +

    New meal

    + +

    Only a verified Head of household can add meals.

    + + + +

    No meals in the library yet.

    + +
      + + +
    • +
      + + + + +
      + + + Edit + + + +
    • + +
    + + + +
    +
    +
    + +
    Meal not found.
    + +
    + +
    + + +
    +
    + +
    + +
    + > + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +

    Per row: name, optional store, quantity, size, notes — store blank uses your first grocery store.

    +
    + '', 'storeId' => '', 'quantity' => '1', 'size' => '', 'description' => '', 'price' => '', 'image' => '']]; + } + foreach ($shopItems as $si): + ?> +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + + Cancel +
    +
    +
    +
    +
    + +
    +
    + +
    + + + + + + diff --git a/tabs/settings.php b/tabs/settings.php new file mode 100644 index 0000000..ec541a8 --- /dev/null +++ b/tabs/settings.php @@ -0,0 +1,225 @@ + 'Permanent', + 'weekly' => 'Weekly', + 'biweekly' => 'Bi-weekly', + 'monthly' => 'Monthly', + 'quarterly' => 'Quarterly', + 'yearly' => 'Yearly', +]; +?> + +
    +

    Family settings

    +

    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.

    + + +
    +
    First-time setup
    +
    +

    Create the first Head of household profile so you can manage family members and settings.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    Chore economy
    +
    + 0): ?> +

    Switch to a verified Head of household to edit these settings.

    + +
    +
    + + > +
    +
    + + > +
    +
    + + +
    +
    + + > +
    +
    + + > +
    + +
    + +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    Grocery stores
    +
    +

    Each store has its own list in the Grocery list tab.

    + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    + +

    Only a verified Head of household can manage stores.

    + + +

    No stores yet. Add one above (or open the Grocery list tab to get a default Home store).

    + +
      + + +
    • + + + + + + + +
    • + +
    + +
    +
    + + 0): ?> +
    +
    + People +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    + +

    Only a verified Head of household can add or edit people.

    + + +
    + + + + + + + + + + + + + + + + + + + + + +
    NameRoleBalance
    + + + + +
    +
    +
    +
    + +