First pass of features
This commit is contained in:
parent
1a65e69d25
commit
6353f9c9b4
23
.cursor/rules/env-example-and-readme.mdc
Normal file
23
.cursor/rules/env-example-and-readme.mdc
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Keep env.example and readme.md documentation in sync
|
||||
globs: env.example,readme.md,**/env.example,**/readme.md
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## env.example and readme.md
|
||||
|
||||
The tracked environment template is `env.example` at the project root (the install wizard parses it; `.env` is local and gitignored).
|
||||
|
||||
**Whenever you add, remove, or rename a variable in `env.example`, you must update `readme.md`** — specifically the **Environment variables** (or equivalent) section.
|
||||
|
||||
For each key in `env.example`, the readme must explain:
|
||||
|
||||
- What the value is for
|
||||
- **Where and how a user should obtain** that value (which product UI, console path, or short steps). Prefer stable patterns such as “Google Cloud Console → APIs & Services → Credentials” rather than fragile deep links where possible; official docs links are fine.
|
||||
|
||||
**Also update the readme when:**
|
||||
|
||||
- Install or env-related behavior changes (e.g. `install.php`, redirects, CLI vs web)
|
||||
- New sections in `env.example` (mirror with a readme subsection and user-facing guidance)
|
||||
|
||||
Do not leave new `env.example` keys undocumented in the readme. The install wizard only reflects `env.example`; human-readable acquisition steps live in the readme.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,6 +11,7 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!env.example
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
|
||||
60
AGENTS.md
Normal file
60
AGENTS.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Family Hub development agents
|
||||
|
||||
Three roles coordinate so features stay usable on phones and desktops, data stays consistent, and releases are verifiable without adding Composer or npm tooling.
|
||||
|
||||
## Roles
|
||||
|
||||
### UI agent
|
||||
|
||||
**Owns**
|
||||
|
||||
- Responsive layout (Bootstrap breakpoints), readable typography, and spacing consistent with [assets/css/style.css](assets/css/style.css).
|
||||
- Touch targets (about 44px minimum where practical) and hover/keyboard affordances on desktop.
|
||||
- Empty states, loading/error feedback for API actions, and color contrast when applying per-person `favoriteColor` theming.
|
||||
- Information architecture for tabs (e.g. store sub-nav in groceries): scannable labels, scroll behavior on small screens.
|
||||
|
||||
**Definition of done**
|
||||
|
||||
- No horizontal overflow on a 320px-wide viewport for primary flows.
|
||||
- Primary actions visible without reliance on hover-only tooltips.
|
||||
|
||||
### Engineering agent
|
||||
|
||||
**Owns**
|
||||
|
||||
- JSON schemas in `data/`, [`includes/db.php`](includes/db.php) read/write patterns (including locking), and `api/*.php` contracts.
|
||||
- Session and persona rules: who can switch to Head of Household (PIN), how PINs are stored (`password_hash` only), and never exposing secrets in HTML or JSON responses.
|
||||
- Migrations from older flat JSON shapes when fields are added; documenting new filenames in export/cron if applicable.
|
||||
|
||||
**Definition of done**
|
||||
|
||||
- Writes use exclusive file locks; reads that must be consistent use shared locks where implemented.
|
||||
- Invalid `tab=` values cannot include arbitrary files ([`index.php`](index.php)).
|
||||
- API errors return JSON with a stable `error` string; success responses are predictable for the UI agent.
|
||||
|
||||
### Testing agent
|
||||
|
||||
**Owns**
|
||||
|
||||
- Manual test matrices per feature (happy path, validation failures, mobile width).
|
||||
- Optional plain-PHP smoke scripts under `scripts/` (no PHPUnit dependency).
|
||||
- Browser checks for critical flows: persona switch, HoH PIN failure/success, settings save.
|
||||
|
||||
**Definition of done**
|
||||
|
||||
- Every feature merged with a short checklist in the PR description or a linked note (until automated smoke tests exist).
|
||||
- Regression entries added when bugs are fixed.
|
||||
|
||||
## How the agents work together
|
||||
|
||||
1. **Engineering** lands data model and API first (or in the same PR as minimal UI).
|
||||
2. **UI** wires the flow against real endpoints; adjusts copy and layout.
|
||||
3. **Testing** runs the matrix; block merge on open P0/P1 gaps.
|
||||
|
||||
**Conflict resolution:** If a shortcut breaks responsive or locking rules, Engineering revises the approach; if the API is awkward for users, UI and Engineering agree on a small contract change before adding UI polish.
|
||||
|
||||
## Conventions
|
||||
|
||||
- PHP: naming per [.cursor/rules/naming-conventions.mdc](.cursor/rules/naming-conventions.mdc).
|
||||
- Front-end libraries: Bootstrap, Font Awesome, and jQuery from CDN only (project rule).
|
||||
- Runtime data lives in gitignored `data/`; committed examples belong under `fixtures/` or `docs/samples/`, not `data/`.
|
||||
40
api/chore_delete.php
Normal file
40
api/chore_delete.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||
$chores = migrateAllChores($rawChores, $people);
|
||||
|
||||
$idx = findChoreIndexById($chores, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||
}
|
||||
|
||||
$existing = $chores[$idx];
|
||||
$isAuthor = ($existing['author_id'] ?? '') === ($actor['id'] ?? '');
|
||||
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
|
||||
if (!$isAuthor && !$isHoH) {
|
||||
sendJson(['success' => false, 'error' => 'You cannot delete this chore'], 403);
|
||||
}
|
||||
|
||||
array_splice($chores, $idx, 1);
|
||||
|
||||
if (!writeJsonFile('chores.json', $chores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
87
api/chore_review.php
Normal file
87
api/chore_review.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
$decision = isset($body['decision']) ? trim((string) $body['decision']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
if (!in_array($decision, ['approve', 'reject'], true)) {
|
||||
sendJson(['success' => false, 'error' => 'decision must be approve or reject'], 400);
|
||||
}
|
||||
|
||||
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||
$chores = migrateAllChores($rawChores, $people);
|
||||
|
||||
$idx = findChoreIndexById($chores, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||
}
|
||||
|
||||
$row = $chores[$idx];
|
||||
$pending = $row['pending_submission'] ?? null;
|
||||
if (!is_array($pending)) {
|
||||
sendJson(['success' => false, 'error' => 'Nothing is waiting for approval on this chore'], 400);
|
||||
}
|
||||
|
||||
if ($decision === 'reject') {
|
||||
$row['pending_submission'] = null;
|
||||
$chores[$idx] = migrateLegacyChoreRow($row, $people);
|
||||
if (!writeJsonFile('chores.json', $chores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||
}
|
||||
sendJson(['success' => true]);
|
||||
}
|
||||
|
||||
$assignees = $row['assignee_ids'] ?? [];
|
||||
if (!is_array($assignees)) {
|
||||
$assignees = [];
|
||||
}
|
||||
$value = (float) ($row['value'] ?? 0);
|
||||
$n = count($assignees);
|
||||
$share = $n > 0 ? round($value / $n, 2) : 0.0;
|
||||
|
||||
$row['pending_submission'] = null;
|
||||
|
||||
if (($row['schedule'] ?? CHORE_SCHEDULE_ONCE) === CHORE_SCHEDULE_RECURRING) {
|
||||
$days = (int) ($row['recurrence_days'] ?? 7);
|
||||
$days = max(1, $days);
|
||||
$row['due_date'] = gmdate('Y-m-d', time() + ($days * 86400));
|
||||
$row['status'] = 'active';
|
||||
} else {
|
||||
$row['status'] = 'completed';
|
||||
}
|
||||
|
||||
$chores[$idx] = migrateLegacyChoreRow($row, $people);
|
||||
|
||||
if (!writeJsonFile('chores.json', $chores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||
}
|
||||
|
||||
foreach ($people as $pi => $p) {
|
||||
$pid = (string) ($p['id'] ?? '');
|
||||
if ($pid === '' || !in_array($pid, $assignees, true)) {
|
||||
continue;
|
||||
}
|
||||
$bal = $p['currency_balance'] ?? 0;
|
||||
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $share;
|
||||
}
|
||||
|
||||
if (!writeJsonFile('people.json', $people)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'credited_each' => $share]);
|
||||
97
api/chore_save.php
Normal file
97
api/chore_save.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||
$chores = migrateAllChores($rawChores, $people);
|
||||
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
|
||||
if ($id !== '') {
|
||||
$idx = findChoreIndexById($chores, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||
}
|
||||
$existing = $chores[$idx];
|
||||
$isAuthor = ($existing['author_id'] ?? '') === ($actor['id'] ?? '');
|
||||
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
|
||||
if (!$isAuthor && !$isHoH) {
|
||||
sendJson(['success' => false, 'error' => 'You cannot edit this chore'], 403);
|
||||
}
|
||||
$row = $existing;
|
||||
} else {
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can create chores'], 403);
|
||||
}
|
||||
$row = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'author_id' => (string) ($actor['id'] ?? ''),
|
||||
'pending_submission' => null,
|
||||
'status' => 'active',
|
||||
];
|
||||
$idx = null;
|
||||
}
|
||||
|
||||
$title = isset($body['title']) ? trim((string) $body['title']) : '';
|
||||
if ($title === '') {
|
||||
sendJson(['success' => false, 'error' => 'Title is required'], 400);
|
||||
}
|
||||
|
||||
$assigneeIds = $body['assignee_ids'] ?? [];
|
||||
if (!is_array($assigneeIds)) {
|
||||
$assigneeIds = [];
|
||||
}
|
||||
$assigneeIdsClean = [];
|
||||
foreach ($assigneeIds as $aid) {
|
||||
$aid = trim((string) $aid);
|
||||
if ($aid !== '') {
|
||||
$assigneeIdsClean[] = $aid;
|
||||
}
|
||||
}
|
||||
$assigneeIdsClean = array_values(array_unique($assigneeIdsClean));
|
||||
|
||||
if (!choreAssigneeIdsValid($assigneeIdsClean, $people)) {
|
||||
sendJson(['success' => false, 'error' => 'One or more assignees are invalid'], 400);
|
||||
}
|
||||
|
||||
$row['title'] = $title;
|
||||
$row['description'] = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
|
||||
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
|
||||
$row['assignee_ids'] = $assigneeIdsClean;
|
||||
|
||||
$val = isset($body['value']) ? $body['value'] : 0;
|
||||
$row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0;
|
||||
|
||||
$due = isset($body['due_date']) ? trim((string) $body['due_date']) : '';
|
||||
if ($due !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $due)) {
|
||||
sendJson(['success' => false, 'error' => 'due_date must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
$row['due_date'] = $due;
|
||||
|
||||
$sched = isset($body['schedule']) ? (string) $body['schedule'] : CHORE_SCHEDULE_ONCE;
|
||||
$row['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
|
||||
|
||||
$rd = isset($body['recurrence_days']) ? (int) $body['recurrence_days'] : ($row['recurrence_days'] ?? 7);
|
||||
$row['recurrence_days'] = max(1, $rd);
|
||||
|
||||
if ($idx === null) {
|
||||
$chores[] = migrateLegacyChoreRow($row, $people);
|
||||
} else {
|
||||
$chores[$idx] = migrateLegacyChoreRow($row, $people);
|
||||
}
|
||||
|
||||
if (!writeJsonFile('chores.json', $chores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'chore' => $row]);
|
||||
59
api/chore_submit.php
Normal file
59
api/chore_submit.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
$actorId = (string) ($actor['id'] ?? '');
|
||||
if ($actorId === '') {
|
||||
sendJson(['success' => false, 'error' => 'Invalid session'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||
$chores = migrateAllChores($rawChores, $people);
|
||||
|
||||
$idx = findChoreIndexById($chores, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||
}
|
||||
|
||||
$row = $chores[$idx];
|
||||
if (($row['status'] ?? '') !== 'active') {
|
||||
sendJson(['success' => false, 'error' => 'This chore is not active'], 400);
|
||||
}
|
||||
|
||||
$assignees = $row['assignee_ids'] ?? [];
|
||||
if (!is_array($assignees) || !in_array($actorId, $assignees, true)) {
|
||||
sendJson(['success' => false, 'error' => 'Only assignees can mark this chore complete'], 403);
|
||||
}
|
||||
|
||||
if (!empty($row['pending_submission']) && is_array($row['pending_submission'])) {
|
||||
sendJson(['success' => false, 'error' => 'This chore is already waiting for approval'], 400);
|
||||
}
|
||||
|
||||
$note = isset($body['note']) ? trim((string) $body['note']) : '';
|
||||
|
||||
$row['pending_submission'] = [
|
||||
'submitted_at' => gmdate('c'),
|
||||
'submitted_by' => $actorId,
|
||||
'note' => $note,
|
||||
];
|
||||
|
||||
$chores[$idx] = migrateLegacyChoreRow($row, $people);
|
||||
|
||||
if (!writeJsonFile('chores.json', $chores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
88
api/expense_create.php
Normal file
88
api/expense_create.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/expense_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can record expenses'], 403);
|
||||
}
|
||||
|
||||
$creatorId = (string) ($actor['id'] ?? '');
|
||||
|
||||
$body = readJsonBody();
|
||||
$title = isset($body['title']) ? trim((string) $body['title']) : '';
|
||||
$description = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$date = isset($body['date']) ? trim((string) $body['date']) : '';
|
||||
$assigneeId = isset($body['assignee_id']) ? trim((string) $body['assignee_id']) : '';
|
||||
|
||||
if ($title === '') {
|
||||
sendJson(['success' => false, 'error' => 'Title is required'], 400);
|
||||
}
|
||||
if ($date === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
sendJson(['success' => false, 'error' => 'date must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
if ($assigneeId === '') {
|
||||
sendJson(['success' => false, 'error' => 'assignee_id is required'], 400);
|
||||
}
|
||||
|
||||
$valRaw = $body['value'] ?? null;
|
||||
if (!is_numeric($valRaw)) {
|
||||
sendJson(['success' => false, 'error' => 'value must be a number'], 400);
|
||||
}
|
||||
$value = round((float) $valRaw, 2);
|
||||
if ($value <= 0) {
|
||||
sendJson(['success' => false, 'error' => 'value must be greater than zero'], 400);
|
||||
}
|
||||
|
||||
$targetIdx = null;
|
||||
foreach ($people as $i => $p) {
|
||||
if (($p['id'] ?? '') === $assigneeId) {
|
||||
$targetIdx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($targetIdx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Assignee not found'], 400);
|
||||
}
|
||||
|
||||
$bal = $people[$targetIdx]['currency_balance'] ?? 0;
|
||||
$bal = is_numeric($bal) ? (float) $bal : 0.0;
|
||||
if ($bal < $value) {
|
||||
sendJson([
|
||||
'success' => false,
|
||||
'error' => 'Insufficient balance: has ' . number_format($bal, 2, '.', '') . ', expense is ' . number_format($value, 2, '.', ''),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$people[$targetIdx]['currency_balance'] = round($bal - $value, 2);
|
||||
|
||||
$expense = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'date' => $date,
|
||||
'value' => $value,
|
||||
'assignee_id' => $assigneeId,
|
||||
'created_at' => gmdate('c'),
|
||||
'created_by' => $creatorId,
|
||||
];
|
||||
|
||||
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
|
||||
$expenses[] = $expense;
|
||||
|
||||
if (!writeJsonFile('people.json', $people)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to update balance'], 500);
|
||||
}
|
||||
if (!writeJsonFile('expenses.json', $expenses)) {
|
||||
$people[$targetIdx]['currency_balance'] = $bal;
|
||||
writeJsonFile('people.json', $people);
|
||||
sendJson(['success' => false, 'error' => 'Failed to save expense; balance was not changed'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'expense' => $expense, 'new_balance' => $people[$targetIdx]['currency_balance']]);
|
||||
48
api/family_settings_save.php
Normal file
48
api/family_settings_save.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/family_settings.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
if (count($people) > 0) {
|
||||
assertHoHCanManagePeople($people);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$merged = loadFamilySettings();
|
||||
|
||||
$allowedPermanence = ['permanent', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
|
||||
|
||||
if (isset($body['currency_symbol'])) {
|
||||
$merged['currency_symbol'] = trim((string) $body['currency_symbol']);
|
||||
}
|
||||
if (isset($body['currency_name'])) {
|
||||
$merged['currency_name'] = trim((string) $body['currency_name']);
|
||||
}
|
||||
if (isset($body['currency_permanence'])) {
|
||||
$p = (string) $body['currency_permanence'];
|
||||
if (!in_array($p, $allowedPermanence, true)) {
|
||||
sendJson(['success' => false, 'error' => 'Invalid currency_permanence'], 400);
|
||||
}
|
||||
$merged['currency_permanence'] = $p;
|
||||
}
|
||||
if (isset($body['timezone'])) {
|
||||
$merged['timezone'] = trim((string) $body['timezone']);
|
||||
}
|
||||
if (isset($body['week_starts_on'])) {
|
||||
$w = (int) $body['week_starts_on'];
|
||||
if ($w < 0 || $w > 6) {
|
||||
sendJson(['success' => false, 'error' => 'week_starts_on must be 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]);
|
||||
78
api/grocery_item_create.php
Normal file
78
api/grocery_item_create.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
if (count($stores) === 0) {
|
||||
sendJson(['success' => false, 'error' => 'No stores available'], 400);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||
if ($storeId === '' || findStoreById($stores, $storeId) === null) {
|
||||
sendJson(['success' => false, 'error' => 'Invalid store'], 400);
|
||||
}
|
||||
|
||||
$name = isset($body['name']) ? trim((string) $body['name']) : '';
|
||||
$description = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$size = isset($body['size']) ? trim((string) $body['size']) : '';
|
||||
$quantity = isset($body['quantity']) ? trim((string) $body['quantity']) : '1';
|
||||
$price = isset($body['price']) ? trim((string) $body['price']) : '';
|
||||
$image = isset($body['image']) ? trim((string) $body['image']) : '';
|
||||
$source = isset($body['source']) ? (string) $body['source'] : 'manual';
|
||||
if (!in_array($source, ['manual', 'meal_plan', 'pending_review'], true)) {
|
||||
$source = 'manual';
|
||||
}
|
||||
$recurring = isset($body['recurringIntervalDays']) ? max(0, (int) $body['recurringIntervalDays']) : 0;
|
||||
|
||||
$catalogPickId = isset($body['catalogPickId']) ? trim((string) $body['catalogPickId']) : '';
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
|
||||
if ($catalogPickId !== '') {
|
||||
$pick = findCatalogById($catalog, $catalogPickId);
|
||||
if ($pick !== null && (string) ($pick['storeId'] ?? '') === $storeId) {
|
||||
if ($name === '') {
|
||||
$name = trim((string) ($pick['name'] ?? ''));
|
||||
}
|
||||
if ($description === '') {
|
||||
$description = trim((string) ($pick['description'] ?? ''));
|
||||
}
|
||||
if ($size === '') {
|
||||
$size = trim((string) ($pick['defaultSize'] ?? ''));
|
||||
}
|
||||
if ($image === '') {
|
||||
$image = trim((string) ($pick['defaultImage'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = groceryAppendShoppingLine(
|
||||
$stores,
|
||||
$storeId,
|
||||
$name,
|
||||
$description,
|
||||
$size,
|
||||
$quantity,
|
||||
$price,
|
||||
$image,
|
||||
$source,
|
||||
$recurring,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
if (!$result['ok']) {
|
||||
sendJson(['success' => false, 'error' => $result['error'] ?? 'Failed'], 400);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'item' => $result['item']]);
|
||||
39
api/grocery_item_delete.php
Normal file
39
api/grocery_item_delete.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
|
||||
if ($storeId === '' || $itemId === '') {
|
||||
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$items = $lists['byStore'][$storeId] ?? [];
|
||||
$next = [];
|
||||
foreach ($items as $it) {
|
||||
if (($it['id'] ?? '') === $itemId) {
|
||||
continue;
|
||||
}
|
||||
$next[] = $it;
|
||||
}
|
||||
if (count($next) === count($items)) {
|
||||
sendJson(['success' => false, 'error' => 'Item not found'], 404);
|
||||
}
|
||||
|
||||
$lists['byStore'][$storeId] = $next;
|
||||
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
70
api/grocery_item_purchase.php
Normal file
70
api/grocery_item_purchase.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
|
||||
$purchased = !empty($body['purchased']);
|
||||
if ($storeId === '' || $itemId === '') {
|
||||
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$items = $lists['byStore'][$storeId] ?? [];
|
||||
$idx = null;
|
||||
foreach ($items as $i => $it) {
|
||||
if (($it['id'] ?? '') === $itemId) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Item not found'], 404);
|
||||
}
|
||||
|
||||
$row = $items[$idx];
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
|
||||
if ($purchased) {
|
||||
$row['status'] = 'purchased';
|
||||
$row['purchasedAt'] = gmdate('c');
|
||||
$cid = $row['catalogId'] ?? null;
|
||||
if (is_string($cid) && $cid !== '') {
|
||||
foreach ($catalog as $ci => $c) {
|
||||
if (($c['id'] ?? '') === $cid) {
|
||||
$catalog[$ci]['lastPurchaseAt'] = gmdate('c');
|
||||
$days = max(0, (int) ($row['recurringIntervalDays'] ?? 0));
|
||||
if ($days > 0) {
|
||||
$catalog[$ci]['recurringIntervalDays'] = $days;
|
||||
$catalog[$ci]['nextDueDate'] = gmdate('Y-m-d', strtotime('+' . $days . ' days'));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$row['status'] = 'active';
|
||||
$row['purchasedAt'] = null;
|
||||
}
|
||||
|
||||
$row = normalizeGroceryLineItem($row);
|
||||
$items[$idx] = $row;
|
||||
$lists['byStore'][$storeId] = $items;
|
||||
|
||||
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save catalog'], 500);
|
||||
}
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
50
api/grocery_item_review.php
Normal file
50
api/grocery_item_review.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can approve pending items'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||
$itemId = isset($body['itemId']) ? trim((string) $body['itemId']) : '';
|
||||
if ($storeId === '' || $itemId === '') {
|
||||
sendJson(['success' => false, 'error' => 'storeId and itemId are required'], 400);
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$items = $lists['byStore'][$storeId] ?? [];
|
||||
$idx = null;
|
||||
foreach ($items as $i => $it) {
|
||||
if (($it['id'] ?? '') === $itemId) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Item not found'], 404);
|
||||
}
|
||||
|
||||
$row = $items[$idx];
|
||||
if (($row['status'] ?? '') !== 'pending_review') {
|
||||
sendJson(['success' => false, 'error' => 'Item is not pending review'], 400);
|
||||
}
|
||||
|
||||
$row['status'] = 'active';
|
||||
$row = normalizeGroceryLineItem($row);
|
||||
$items[$idx] = $row;
|
||||
$lists['byStore'][$storeId] = $items;
|
||||
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save grocery list'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
39
api/meal_delete.php
Normal file
39
api/meal_delete.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
$actorId = (string) ($actor['id'] ?? '');
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||
$idx = findMealIndexById($meals, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
|
||||
}
|
||||
|
||||
$existing = $meals[$idx];
|
||||
$isAuthor = ($existing['author_id'] ?? '') === $actorId;
|
||||
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
|
||||
if (!$isAuthor && !$isHoH) {
|
||||
sendJson(['success' => false, 'error' => 'You cannot delete this meal'], 403);
|
||||
}
|
||||
|
||||
array_splice($meals, $idx, 1);
|
||||
|
||||
if (!writeJsonFile('meals.json', $meals)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save meals'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
59
api/meal_ingredient_to_grocery.php
Normal file
59
api/meal_ingredient_to_grocery.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
|
||||
$body = readJsonBody();
|
||||
$mealId = isset($body['mealId']) ? trim((string) $body['mealId']) : '';
|
||||
$ingredient = isset($body['ingredient']) ? trim((string) $body['ingredient']) : '';
|
||||
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||
|
||||
if ($ingredient === '') {
|
||||
sendJson(['success' => false, 'error' => 'ingredient is required'], 400);
|
||||
}
|
||||
if (count($stores) === 0) {
|
||||
sendJson(['success' => false, 'error' => 'Add a grocery store first'], 400);
|
||||
}
|
||||
|
||||
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||
$meal = $mealId !== '' ? findMealById($meals, $mealId) : null;
|
||||
$mealTitle = $meal !== null ? (string) ($meal['title'] ?? '') : '';
|
||||
|
||||
if ($storeId === '' || findStoreById($stores, $storeId) === null) {
|
||||
$storeId = groceryFirstStoreId($stores);
|
||||
}
|
||||
if ($storeId === '') {
|
||||
sendJson(['success' => false, 'error' => 'No valid store'], 400);
|
||||
}
|
||||
|
||||
$res = groceryAppendShoppingLine(
|
||||
$stores,
|
||||
$storeId,
|
||||
$ingredient,
|
||||
$mealTitle !== '' ? 'From meal: ' . $mealTitle : 'From meal ingredient',
|
||||
'',
|
||||
'1',
|
||||
'',
|
||||
'',
|
||||
'meal_plan',
|
||||
0,
|
||||
$mealId !== '' ? $mealId : null,
|
||||
$mealTitle !== '' ? $mealTitle : null
|
||||
);
|
||||
|
||||
if (!$res['ok']) {
|
||||
sendJson(['success' => false, 'error' => $res['error'] ?? 'Failed'], 400);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'item' => $res['item']]);
|
||||
68
api/meal_plan_set_slot.php
Normal file
68
api/meal_plan_set_slot.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
|
||||
$body = readJsonBody();
|
||||
$weekStart = isset($body['weekStart']) ? trim((string) $body['weekStart']) : '';
|
||||
$day = isset($body['day']) ? (int) $body['day'] : -1;
|
||||
$mealType = isset($body['mealType']) ? trim((string) $body['mealType']) : '';
|
||||
$mealId = isset($body['mealId']) ? trim((string) $body['mealId']) : '';
|
||||
$mealId = $mealId === '' ? null : $mealId;
|
||||
$pushGrocery = array_key_exists('pushGrocery', $body) ? !empty($body['pushGrocery']) : true;
|
||||
|
||||
if ($weekStart === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $weekStart)) {
|
||||
sendJson(['success' => false, 'error' => 'weekStart must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
if ($day < 0 || $day > 6) {
|
||||
sendJson(['success' => false, 'error' => 'day must be 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]);
|
||||
31
api/meal_plan_set_week.php
Normal file
31
api/meal_plan_set_week.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can change the planning week'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$weekStart = isset($body['weekStart']) ? trim((string) $body['weekStart']) : '';
|
||||
if ($weekStart === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $weekStart)) {
|
||||
sendJson(['success' => false, 'error' => 'weekStart must be YYYY-MM-DD (use the Monday of the week)'], 400);
|
||||
}
|
||||
|
||||
$plan = [
|
||||
'weekStart' => $weekStart,
|
||||
'slots' => mealDefaultEmptySlots(),
|
||||
];
|
||||
|
||||
if (!writeJsonFile('meal_plans.json', $plan)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save meal plan'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'plan' => $plan]);
|
||||
34
api/meal_push_grocery.php
Normal file
34
api/meal_push_grocery.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
requireActivePerson($people);
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||
$meal = findMealById($meals, $id);
|
||||
if ($meal === null) {
|
||||
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
|
||||
}
|
||||
|
||||
if (count($stores) === 0) {
|
||||
sendJson(['success' => false, 'error' => 'Add a grocery store first'], 400);
|
||||
}
|
||||
|
||||
$n = pushMealItemsToGrocery(normalizeMealRow($meal), $stores);
|
||||
sendJson(['success' => true, 'groceryLinesAdded' => $n]);
|
||||
69
api/meal_save.php
Normal file
69
api/meal_save.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
$actorId = (string) ($actor['id'] ?? '');
|
||||
|
||||
$body = readJsonBody();
|
||||
$meals = normalizeMealsList(readJsonFile('meals.json'));
|
||||
$meals = migrateLegacyMealsList($meals);
|
||||
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
|
||||
if ($id !== '') {
|
||||
$idx = findMealIndexById($meals, $id);
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
|
||||
}
|
||||
$existing = $meals[$idx];
|
||||
$isAuthor = ($existing['author_id'] ?? '') === $actorId;
|
||||
$isHoH = ($actor['role'] ?? '') === ROLE_HEAD && isHohVerified();
|
||||
if (!$isAuthor && !$isHoH) {
|
||||
sendJson(['success' => false, 'error' => 'You cannot edit this meal'], 403);
|
||||
}
|
||||
$row = $existing;
|
||||
} else {
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can create meals'], 403);
|
||||
}
|
||||
$row = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'author_id' => $actorId,
|
||||
];
|
||||
$idx = null;
|
||||
}
|
||||
|
||||
$title = isset($body['title']) ? trim((string) $body['title']) : '';
|
||||
if ($title === '') {
|
||||
sendJson(['success' => false, 'error' => 'Title is required'], 400);
|
||||
}
|
||||
|
||||
$row['title'] = $title;
|
||||
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
|
||||
$row['description'] = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$row['directions'] = isset($body['directions']) ? trim((string) $body['directions']) : '';
|
||||
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
|
||||
$row['tags'] = $body['tags'] ?? [];
|
||||
$row['ingredients'] = $body['ingredients'] ?? [];
|
||||
$row['items'] = $body['items'] ?? [];
|
||||
|
||||
$row = normalizeMealRow($row);
|
||||
|
||||
if ($idx === null) {
|
||||
$meals[] = $row;
|
||||
} else {
|
||||
$meals[$idx] = $row;
|
||||
}
|
||||
|
||||
if (!writeJsonFile('meals.json', $meals)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save meals'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'meal' => $row]);
|
||||
71
api/people_create.php
Normal file
71
api/people_create.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
if (count($people) === 0) {
|
||||
sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400);
|
||||
}
|
||||
assertHoHCanManagePeople($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$name = isset($body['name']) ? trim((string) $body['name']) : '';
|
||||
$role = isset($body['role']) ? (string) $body['role'] : '';
|
||||
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
|
||||
|
||||
if ($name === '') {
|
||||
sendJson(['success' => false, 'error' => 'Name is required'], 400);
|
||||
}
|
||||
|
||||
$allowedRoles = [ROLE_HEAD, ROLE_ADULT, ROLE_CHILD];
|
||||
if (!in_array($role, $allowedRoles, true)) {
|
||||
sendJson(['success' => false, 'error' => 'Invalid role'], 400);
|
||||
}
|
||||
|
||||
if ($role === ROLE_HEAD) {
|
||||
if (strlen($pin) < 4) {
|
||||
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters'], 400);
|
||||
}
|
||||
$pinHash = password_hash($pin, PASSWORD_DEFAULT);
|
||||
} else {
|
||||
$pinHash = null;
|
||||
}
|
||||
|
||||
$icon = isset($body['icon']) ? trim((string) $body['icon']) : '';
|
||||
$description = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$birthday = isset($body['birthday']) ? trim((string) $body['birthday']) : '';
|
||||
$favoriteColor = isset($body['favoriteColor']) ? trim((string) $body['favoriteColor']) : '#4a90e2';
|
||||
|
||||
if ($favoriteColor !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $favoriteColor)) {
|
||||
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
|
||||
}
|
||||
|
||||
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
|
||||
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
|
||||
$newPerson = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'name' => $name,
|
||||
'role' => $role,
|
||||
'pin_hash' => $pinHash,
|
||||
'icon' => $icon,
|
||||
'description' => $description,
|
||||
'birthday' => $birthday,
|
||||
'favoriteColor' => $favoriteColor,
|
||||
'currency_balance' => 0,
|
||||
'created_at' => gmdate('c'),
|
||||
];
|
||||
|
||||
$people[] = $newPerson;
|
||||
if (!writeJsonFile('people.json', $people)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||
}
|
||||
|
||||
$safe = $newPerson;
|
||||
unset($safe['pin_hash']);
|
||||
sendJson(['success' => true, 'person' => $safe]);
|
||||
59
api/people_create_first.php
Normal file
59
api/people_create_first.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
if (count($people) > 0) {
|
||||
sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$name = isset($body['name']) ? trim((string) $body['name']) : '';
|
||||
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
|
||||
|
||||
if ($name === '') {
|
||||
sendJson(['success' => false, 'error' => 'Name is required'], 400);
|
||||
}
|
||||
|
||||
if (strlen($pin) < 4) {
|
||||
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters'], 400);
|
||||
}
|
||||
|
||||
$icon = isset($body['icon']) ? trim((string) $body['icon']) : '';
|
||||
$description = isset($body['description']) ? trim((string) $body['description']) : '';
|
||||
$birthday = isset($body['birthday']) ? trim((string) $body['birthday']) : '';
|
||||
$favoriteColor = isset($body['favoriteColor']) ? trim((string) $body['favoriteColor']) : '#4a90e2';
|
||||
|
||||
if ($favoriteColor !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $favoriteColor)) {
|
||||
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
|
||||
}
|
||||
|
||||
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
|
||||
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
|
||||
$newPerson = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'name' => $name,
|
||||
'role' => ROLE_HEAD,
|
||||
'pin_hash' => password_hash($pin, PASSWORD_DEFAULT),
|
||||
'icon' => $icon,
|
||||
'description' => $description,
|
||||
'birthday' => $birthday,
|
||||
'favoriteColor' => $favoriteColor,
|
||||
'currency_balance' => 0,
|
||||
'created_at' => gmdate('c'),
|
||||
];
|
||||
|
||||
if (!writeJsonFile('people.json', [$newPerson])) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||
}
|
||||
|
||||
setSessionPerson($newPerson['id'], true);
|
||||
$safe = $newPerson;
|
||||
unset($safe['pin_hash']);
|
||||
sendJson(['success' => true, 'person' => $safe]);
|
||||
50
api/people_delete.php
Normal file
50
api/people_delete.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
assertHoHCanManagePeople($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$next = [];
|
||||
$removed = null;
|
||||
foreach ($people as $p) {
|
||||
if (($p['id'] ?? '') === $id) {
|
||||
$removed = $p;
|
||||
continue;
|
||||
}
|
||||
$next[] = $p;
|
||||
}
|
||||
|
||||
if ($removed === null) {
|
||||
sendJson(['success' => false, 'error' => 'Person not found'], 404);
|
||||
}
|
||||
|
||||
$headCount = 0;
|
||||
foreach ($next as $p) {
|
||||
if (($p['role'] ?? '') === ROLE_HEAD) {
|
||||
$headCount++;
|
||||
}
|
||||
}
|
||||
if ($headCount < 1) {
|
||||
sendJson(['success' => false, 'error' => 'Cannot remove the last Head of Household'], 400);
|
||||
}
|
||||
|
||||
if (!writeJsonFile('people.json', $next)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||
}
|
||||
|
||||
if (getActivePersonId() === $id) {
|
||||
clearPersonaSession();
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
48
api/people_reset_pin.php
Normal file
48
api/people_reset_pin.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
assertHoHCanManagePeople($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$targetId = isset($body['targetPersonId']) ? trim((string) $body['targetPersonId']) : '';
|
||||
$newPin = isset($body['newPin']) ? (string) $body['newPin'] : '';
|
||||
|
||||
if ($targetId === '') {
|
||||
sendJson(['success' => false, 'error' => 'targetPersonId is required'], 400);
|
||||
}
|
||||
if (strlen($newPin) < 4) {
|
||||
sendJson(['success' => false, 'error' => 'newPin must be at least 4 characters'], 400);
|
||||
}
|
||||
|
||||
$target = findPersonById($people, $targetId);
|
||||
if ($target === null) {
|
||||
sendJson(['success' => false, 'error' => 'Person not found'], 404);
|
||||
}
|
||||
if (($target['role'] ?? '') !== ROLE_HEAD) {
|
||||
sendJson(['success' => false, 'error' => 'PIN applies only to Head of Household profiles'], 400);
|
||||
}
|
||||
|
||||
$updated = false;
|
||||
foreach ($people as $i => $p) {
|
||||
if (($p['id'] ?? '') === $targetId) {
|
||||
$people[$i]['pin_hash'] = password_hash($newPin, PASSWORD_DEFAULT);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$updated) {
|
||||
sendJson(['success' => false, 'error' => 'Update failed'], 500);
|
||||
}
|
||||
|
||||
if (!writeJsonFile('people.json', $people)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
96
api/people_update.php
Normal file
96
api/people_update.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
assertHoHCanManagePeople($people);
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$idx = null;
|
||||
foreach ($people as $i => $p) {
|
||||
if (($p['id'] ?? '') === $id) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($idx === null) {
|
||||
sendJson(['success' => false, 'error' => 'Person not found'], 404);
|
||||
}
|
||||
|
||||
if (isset($body['name'])) {
|
||||
$name = trim((string) $body['name']);
|
||||
if ($name === '') {
|
||||
sendJson(['success' => false, 'error' => 'Name cannot be empty'], 400);
|
||||
}
|
||||
$people[$idx]['name'] = $name;
|
||||
}
|
||||
|
||||
if (array_key_exists('icon', $body)) {
|
||||
$people[$idx]['icon'] = trim((string) $body['icon']);
|
||||
}
|
||||
if (array_key_exists('description', $body)) {
|
||||
$people[$idx]['description'] = trim((string) $body['description']);
|
||||
}
|
||||
if (array_key_exists('birthday', $body)) {
|
||||
$birthday = trim((string) $body['birthday']);
|
||||
if ($birthday !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthday)) {
|
||||
sendJson(['success' => false, 'error' => 'birthday must be YYYY-MM-DD'], 400);
|
||||
}
|
||||
$people[$idx]['birthday'] = $birthday;
|
||||
}
|
||||
if (array_key_exists('favoriteColor', $body)) {
|
||||
$c = trim((string) $body['favoriteColor']);
|
||||
if ($c !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $c)) {
|
||||
sendJson(['success' => false, 'error' => 'favoriteColor must be a #RRGGBB value'], 400);
|
||||
}
|
||||
if ($c !== '') {
|
||||
$people[$idx]['favoriteColor'] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('role', $body)) {
|
||||
$newRole = (string) $body['role'];
|
||||
$allowedRoles = [ROLE_HEAD, ROLE_ADULT, ROLE_CHILD];
|
||||
if (!in_array($newRole, $allowedRoles, true)) {
|
||||
sendJson(['success' => false, 'error' => 'Invalid role'], 400);
|
||||
}
|
||||
$wasHead = ($people[$idx]['role'] ?? '') === ROLE_HEAD;
|
||||
if ($newRole === ROLE_HEAD && !$wasHead) {
|
||||
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
|
||||
if (strlen($pin) < 4) {
|
||||
sendJson(['success' => false, 'error' => 'PIN must be at least 4 characters when promoting to Head of Household'], 400);
|
||||
}
|
||||
$people[$idx]['pin_hash'] = password_hash($pin, PASSWORD_DEFAULT);
|
||||
}
|
||||
if ($newRole !== ROLE_HEAD && $wasHead) {
|
||||
$people[$idx]['pin_hash'] = null;
|
||||
}
|
||||
$people[$idx]['role'] = $newRole;
|
||||
}
|
||||
|
||||
$headCount = 0;
|
||||
foreach ($people as $p) {
|
||||
if (($p['role'] ?? '') === ROLE_HEAD) {
|
||||
$headCount++;
|
||||
}
|
||||
}
|
||||
if ($headCount < 1) {
|
||||
sendJson(['success' => false, 'error' => 'At least one Head of Household is required'], 400);
|
||||
}
|
||||
|
||||
if (!writeJsonFile('people.json', $people)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||
}
|
||||
|
||||
$safe = $people[$idx];
|
||||
unset($safe['pin_hash']);
|
||||
sendJson(['success' => true, 'person' => $safe]);
|
||||
39
api/store_create.php
Normal file
39
api/store_create.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$name = isset($body['name']) ? trim((string) $body['name']) : '';
|
||||
if ($name === '') {
|
||||
sendJson(['success' => false, 'error' => 'Name is required'], 400);
|
||||
}
|
||||
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
$newId = bin2hex(random_bytes(8));
|
||||
$sort = count($stores);
|
||||
$stores[] = ['id' => $newId, 'name' => $name, 'sort' => $sort];
|
||||
|
||||
if (!writeJsonFile('stores.json', $stores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
if (!isset($lists['byStore'][$newId])) {
|
||||
$lists['byStore'][$newId] = [];
|
||||
}
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to init store list'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true, 'store' => ['id' => $newId, 'name' => $name, 'sort' => $sort]]);
|
||||
61
api/store_delete.php
Normal file
61
api/store_delete.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
if ($id === '') {
|
||||
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||
}
|
||||
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
if (count($stores) <= 1) {
|
||||
sendJson(['success' => false, 'error' => 'You must keep at least one store'], 400);
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
if (groceryStoreHasItems($lists, $id)) {
|
||||
sendJson(['success' => false, 'error' => 'Remove or purchase all items in this store before deleting it'], 400);
|
||||
}
|
||||
|
||||
$next = [];
|
||||
foreach ($stores as $s) {
|
||||
if (($s['id'] ?? '') === $id) {
|
||||
continue;
|
||||
}
|
||||
$next[] = $s;
|
||||
}
|
||||
|
||||
unset($lists['byStore'][$id]);
|
||||
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
$catalogNext = [];
|
||||
foreach ($catalog as $row) {
|
||||
if ((string) ($row['storeId'] ?? '') === $id) {
|
||||
continue;
|
||||
}
|
||||
$catalogNext[] = $row;
|
||||
}
|
||||
|
||||
if (!writeJsonFile('stores.json', $next)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
|
||||
}
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save grocery lists'], 500);
|
||||
}
|
||||
if (!writeJsonFile('grocery_catalog.json', $catalogNext)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save catalog'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
43
api/store_update.php
Normal file
43
api/store_update.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$actor = requireActivePerson($people);
|
||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage stores'], 403);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||
$name = isset($body['name']) ? trim((string) $body['name']) : '';
|
||||
if ($id === '' || $name === '') {
|
||||
sendJson(['success' => false, 'error' => 'id and name are required'], 400);
|
||||
}
|
||||
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
$found = false;
|
||||
foreach ($stores as $i => $s) {
|
||||
if (($s['id'] ?? '') === $id) {
|
||||
$stores[$i]['name'] = $name;
|
||||
if (isset($body['sort']) && is_numeric($body['sort'])) {
|
||||
$stores[$i]['sort'] = (int) $body['sort'];
|
||||
}
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
sendJson(['success' => false, 'error' => 'Store not found'], 404);
|
||||
}
|
||||
|
||||
if (!writeJsonFile('stores.json', $stores)) {
|
||||
sendJson(['success' => false, 'error' => 'Failed to save stores'], 500);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
35
api/switch_person.php
Normal file
35
api/switch_person.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
$personId = isset($body['personId']) ? trim((string) $body['personId']) : '';
|
||||
$pin = isset($body['pin']) ? (string) $body['pin'] : '';
|
||||
|
||||
if ($personId === '') {
|
||||
sendJson(['success' => false, 'error' => 'personId is required'], 400);
|
||||
}
|
||||
|
||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||
$person = findPersonById($people, $personId);
|
||||
if ($person === null) {
|
||||
sendJson(['success' => false, 'error' => 'Person not found'], 404);
|
||||
}
|
||||
|
||||
$role = $person['role'] ?? '';
|
||||
$pinHash = $person['pin_hash'] ?? null;
|
||||
|
||||
if ($role === ROLE_HEAD && is_string($pinHash) && $pinHash !== '') {
|
||||
if ($pin === '' || !password_verify($pin, $pinHash)) {
|
||||
sendJson(['success' => false, 'error' => 'PIN required or incorrect'], 403);
|
||||
}
|
||||
setSessionPerson($personId, true);
|
||||
} else {
|
||||
setSessionPerson($personId, false);
|
||||
}
|
||||
|
||||
sendJson(['success' => true]);
|
||||
@ -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;
|
||||
|
||||
@ -1,25 +1,795 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Tab functionality
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
/**
|
||||
* Tabs use full page navigation (?tab=) from index.php; active state is set server-side.
|
||||
* Do not programmatically .click() <a class="tab"> links on load — that re-follows href and reloads in a tight loop.
|
||||
*/
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Remove active class from all tabs
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tabContents.forEach(content => content.style.display = 'none');
|
||||
(function ($) {
|
||||
'use strict';
|
||||
|
||||
// Add active class to clicked tab
|
||||
tab.classList.add('active');
|
||||
|
||||
// Show corresponding content
|
||||
const targetId = tab.getAttribute('data-target');
|
||||
document.getElementById(targetId).style.display = 'block';
|
||||
var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api';
|
||||
|
||||
function postJson(path, body) {
|
||||
return fetch(apiBase + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (res) {
|
||||
return res.json().then(function (data) {
|
||||
if (!res.ok) {
|
||||
var err = new Error(data.error || res.statusText || 'Request failed');
|
||||
err.payload = data;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showAlert($el, kind, message) {
|
||||
if (!$el || !$el.length) {
|
||||
return;
|
||||
}
|
||||
$el.removeClass('d-none alert-success alert-danger alert-warning');
|
||||
$el.addClass('alert-' + kind);
|
||||
$el.text(message);
|
||||
}
|
||||
|
||||
var pendingPersonId = null;
|
||||
var pinModal = null;
|
||||
|
||||
function switchPerson(personId, pin) {
|
||||
return postJson('/switch_person.php', { personId: personId, pin: pin || '' });
|
||||
}
|
||||
|
||||
function bindPersonaSwitcher() {
|
||||
$(document).on('click', '.persona-chip', function () {
|
||||
var $btn = $(this);
|
||||
var id = $btn.data('person-id');
|
||||
var needsPin = String($btn.data('needs-pin')) === '1';
|
||||
var name = $btn.data('person-name') || 'this profile';
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsPin) {
|
||||
pendingPersonId = id;
|
||||
$('#hohPinModalPrompt').text('Enter PIN for ' + name + '.');
|
||||
$('#hohPinInput').val('');
|
||||
$('#hohPinError').addClass('d-none').text('');
|
||||
if (!pinModal && typeof bootstrap !== 'undefined') {
|
||||
pinModal = new bootstrap.Modal(document.getElementById('hohPinModal'));
|
||||
}
|
||||
if (pinModal) {
|
||||
pinModal.show();
|
||||
}
|
||||
setTimeout(function () {
|
||||
document.getElementById('hohPinInput').focus();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
switchPerson(id, '')
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not switch profile');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize first tab as active
|
||||
if (tabs.length > 0) {
|
||||
tabs[0].click();
|
||||
$('#hohPinSubmit').on('click', function () {
|
||||
var pin = $('#hohPinInput').val() || '';
|
||||
if (!pendingPersonId) {
|
||||
return;
|
||||
}
|
||||
$('#hohPinError').addClass('d-none').text('');
|
||||
switchPerson(pendingPersonId, pin)
|
||||
.then(function () {
|
||||
if (pinModal) {
|
||||
pinModal.hide();
|
||||
}
|
||||
pendingPersonId = null;
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
$('#hohPinError').removeClass('d-none').text(err.message || 'PIN incorrect');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindFirstHeadForm() {
|
||||
var $form = $('#firstHeadForm');
|
||||
if (!$form.length) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var payload = {
|
||||
name: $('#first_name').val(),
|
||||
pin: $('#first_pin').val(),
|
||||
favoriteColor: $('#first_favoriteColor').val()
|
||||
};
|
||||
postJson('/people_create_first.php', payload)
|
||||
.then(function () {
|
||||
showAlert($('#firstHeadFeedback'), 'success', 'Profile created. Reloading…');
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#firstHeadFeedback'), 'danger', err.message || 'Could not create profile');
|
||||
$('#firstHeadFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAddPinVisibility() {
|
||||
var role = $('#add_role').val();
|
||||
var $wrap = $('#add_pin_wrap');
|
||||
var $pin = $('#add_pin');
|
||||
if (role === 'head_of_household') {
|
||||
$wrap.show();
|
||||
$pin.prop('required', true);
|
||||
} else {
|
||||
$wrap.hide();
|
||||
$pin.prop('required', false);
|
||||
$pin.val('');
|
||||
}
|
||||
}
|
||||
|
||||
function bindAddPersonForm() {
|
||||
var $form = $('#addPersonForm');
|
||||
if (!$form.length) {
|
||||
return;
|
||||
}
|
||||
$('#add_role').on('change', toggleAddPinVisibility);
|
||||
toggleAddPinVisibility();
|
||||
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var role = $('#add_role').val();
|
||||
var payload = {
|
||||
name: $('#add_name').val(),
|
||||
role: role,
|
||||
pin: role === 'head_of_household' ? $('#add_pin').val() : '',
|
||||
favoriteColor: $('#add_favoriteColor').val()
|
||||
};
|
||||
postJson('/people_create.php', payload)
|
||||
.then(function () {
|
||||
showAlert($('#addPersonFeedback'), 'success', 'Person added. Reloading…');
|
||||
$('#addPersonFeedback').removeClass('d-none');
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#addPersonFeedback'), 'danger', err.message || 'Could not add person');
|
||||
$('#addPersonFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindFamilySettingsForm() {
|
||||
var $form = $('#familySettingsForm');
|
||||
if (!$form.length) {
|
||||
return;
|
||||
}
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var payload = {
|
||||
currency_symbol: $('#currency_symbol').val(),
|
||||
currency_name: $('#currency_name').val(),
|
||||
currency_permanence: $('#currency_permanence').val(),
|
||||
timezone: $('#timezone').val(),
|
||||
week_starts_on: parseInt($('#week_starts_on').val(), 10)
|
||||
};
|
||||
postJson('/family_settings_save.php', payload)
|
||||
.then(function () {
|
||||
showAlert($('#familySettingsFeedback'), 'success', 'Saved.');
|
||||
$('#familySettingsFeedback').removeClass('d-none');
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not save');
|
||||
$('#familySettingsFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function choreListPayloadFromForm() {
|
||||
var type = $('#chore_list_type').val() || 'checkbox';
|
||||
var raw = $('#chore_list_items').val() || '';
|
||||
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ type: type, items: lines }];
|
||||
}
|
||||
|
||||
function toggleChoreRecurrence() {
|
||||
var sched = $('#chore_schedule').val();
|
||||
if (sched === 'recurring') {
|
||||
$('#chore_recurrence_wrap').show();
|
||||
} else {
|
||||
$('#chore_recurrence_wrap').hide();
|
||||
}
|
||||
}
|
||||
|
||||
function bindChoresPage() {
|
||||
var $form = $('#choreForm');
|
||||
if (!$form.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#chore_schedule').on('change', toggleChoreRecurrence);
|
||||
toggleChoreRecurrence();
|
||||
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var assignees = [];
|
||||
$('.chore-assignee:checked').each(function () {
|
||||
assignees.push($(this).val());
|
||||
});
|
||||
var due = $('#chore_due_date').val() || '';
|
||||
var payload = {
|
||||
id: ($('#chore_id').val() || '').trim(),
|
||||
title: $('#chore_title').val(),
|
||||
description: $('#chore_description').val(),
|
||||
image: $('#chore_image').val(),
|
||||
value: Number($('#chore_value').val()) || 0,
|
||||
due_date: due,
|
||||
schedule: $('#chore_schedule').val(),
|
||||
recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7,
|
||||
assignee_ids: assignees,
|
||||
lists: choreListPayloadFromForm()
|
||||
};
|
||||
postJson('/chore_save.php', payload)
|
||||
.then(function () {
|
||||
window.location.href = window.location.pathname + '?tab=chores';
|
||||
})
|
||||
.catch(function (err) {
|
||||
var $fb = $('#choreFormFeedback');
|
||||
showAlert($fb, 'danger', err.message || 'Could not save chore');
|
||||
$fb.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$('#choreDeleteBtn').on('click', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Delete this chore?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/chore_delete.php', { id: id })
|
||||
.then(function () {
|
||||
window.location.href = window.location.pathname + '?tab=chores';
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not delete');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-chore-submit', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Submit this chore as done? A Head of household will approve it before the reward is paid.')) {
|
||||
return;
|
||||
}
|
||||
postJson('/chore_submit.php', { id: id, note: '' })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not submit');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-chore-approve', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Approve and pay the reward?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/chore_review.php', { id: id, decision: 'approve' })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not approve');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-chore-reject', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Reject this submission?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/chore_review.php', { id: id, decision: 'reject' })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not reject');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function groceryReloadKeepParams() {
|
||||
var $tab = $('#groceries');
|
||||
var store = $tab.data('store-id') || $('#grocery_store_id').val();
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var filter = params.get('filter') || 'active';
|
||||
var q = '?tab=groceries&store=' + encodeURIComponent(store || '') + '&filter=' + encodeURIComponent(filter);
|
||||
window.location.href = window.location.pathname + q;
|
||||
}
|
||||
|
||||
function bindStoreManagement() {
|
||||
$('#storeAddForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var name = ($('#new_store_name').val() || '').trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
postJson('/store_create.php', { name: name })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var $fb = $('#storeAddFeedback');
|
||||
showAlert($fb, 'danger', err.message || 'Could not add store');
|
||||
$fb.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-store-delete', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Delete this store? Lists must be empty.')) {
|
||||
return;
|
||||
}
|
||||
postJson('/store_delete.php', { id: id })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not delete');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-store-rename', function () {
|
||||
var id = $(this).data('id');
|
||||
var current = $(this).data('name') || '';
|
||||
var name = window.prompt('Store name', current);
|
||||
if (name === null) {
|
||||
return;
|
||||
}
|
||||
name = String(name).trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
postJson('/store_update.php', { id: id, name: name })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not rename');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindGroceryPage() {
|
||||
var $tab = $('#groceries');
|
||||
if (!$tab.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#grocery_catalog_pick').on('change', function () {
|
||||
var v = $(this).val();
|
||||
if (!v) {
|
||||
return;
|
||||
}
|
||||
var $o = $(this).find('option:selected');
|
||||
$('#grocery_name').val($o.data('name') || '');
|
||||
$('#grocery_description').val($o.data('description') || '');
|
||||
$('#grocery_size').val($o.data('size') || '');
|
||||
$('#grocery_image').val($o.data('image') || '');
|
||||
});
|
||||
|
||||
$('#groceryAddForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var storeId = ($('#grocery_store_id').val() || $tab.data('store-id') || '').trim();
|
||||
var payload = {
|
||||
storeId: storeId,
|
||||
catalogPickId: $('#grocery_catalog_pick').val() || '',
|
||||
name: $('#grocery_name').val(),
|
||||
description: $('#grocery_description').val(),
|
||||
size: $('#grocery_size').val(),
|
||||
quantity: $('#grocery_quantity').val(),
|
||||
price: $('#grocery_price').val(),
|
||||
image: $('#grocery_image').val(),
|
||||
recurringIntervalDays: parseInt($('#grocery_recurring').val(), 10) || 0,
|
||||
source: 'manual'
|
||||
};
|
||||
postJson('/grocery_item_create.php', payload)
|
||||
.then(function () {
|
||||
groceryReloadKeepParams();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var $fb = $('#groceryFormFeedback');
|
||||
showAlert($fb, 'danger', err.message || 'Could not add item');
|
||||
$fb.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-grocery-purchase', function () {
|
||||
var storeId = $tab.data('store-id');
|
||||
var itemId = $(this).data('item-id');
|
||||
var purchased = String($(this).data('purchased')) === '1';
|
||||
postJson('/grocery_item_purchase.php', {
|
||||
storeId: storeId,
|
||||
itemId: itemId,
|
||||
purchased: purchased
|
||||
})
|
||||
.then(function () {
|
||||
groceryReloadKeepParams();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not update');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-grocery-delete', function () {
|
||||
var storeId = $tab.data('store-id');
|
||||
var itemId = $(this).data('item-id');
|
||||
if (!itemId || !window.confirm('Remove this item?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/grocery_item_delete.php', { storeId: storeId, itemId: itemId })
|
||||
.then(function () {
|
||||
groceryReloadKeepParams();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not remove');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-grocery-approve', function () {
|
||||
var storeId = $tab.data('store-id');
|
||||
var itemId = $(this).data('item-id');
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
postJson('/grocery_item_review.php', { storeId: storeId, itemId: itemId })
|
||||
.then(function () {
|
||||
groceryReloadKeepParams();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not approve');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mealListPayloadFromForm() {
|
||||
var type = $('#meal_list_type').val() || 'checkbox';
|
||||
var raw = $('#meal_list_items').val() || '';
|
||||
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ type: type, items: lines }];
|
||||
}
|
||||
|
||||
function mealGroceryRowsPayload() {
|
||||
var out = [];
|
||||
$('#mealGroceryRows .meal-grocery-row').each(function () {
|
||||
var $r = $(this);
|
||||
var name = ($r.find('.g-name').val() || '').trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
out.push({
|
||||
name: name,
|
||||
storeId: ($r.find('.g-store').val() || '').trim(),
|
||||
quantity: ($r.find('.g-qty').val() || '1').trim() || '1',
|
||||
size: ($r.find('.g-size').val() || '').trim(),
|
||||
description: '',
|
||||
price: '',
|
||||
image: ''
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function readMealsLibrary() {
|
||||
var el = document.getElementById('mealsLibraryJson');
|
||||
if (!el || !el.textContent) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(el.textContent);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hideMealSlotModal() {
|
||||
var el = document.getElementById('mealSlotModal');
|
||||
if (el && typeof bootstrap !== 'undefined') {
|
||||
var inst = bootstrap.Modal.getInstance(el);
|
||||
if (inst) {
|
||||
inst.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindMealsPage() {
|
||||
var $tab = $('#meals');
|
||||
if (!$tab.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var weekStart = ($tab.attr('data-week-start') || '').trim();
|
||||
|
||||
$('#btnPushMealGrocery').on('click', function () {
|
||||
var id = $(this).data('meal-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
postJson('/meal_push_grocery.php', { id: id })
|
||||
.then(function (data) {
|
||||
var n = data.groceryLinesAdded != null ? data.groceryLinesAdded : 0;
|
||||
showAlert($('#pushMealFeedback'), 'success', 'Added ' + n + ' line(s) to grocery (pending review).');
|
||||
$('#pushMealFeedback').removeClass('d-none');
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#pushMealFeedback'), 'danger', err.message || 'Could not push');
|
||||
$('#pushMealFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-ingredient-to-grocery', function () {
|
||||
var $btn = $(this);
|
||||
var mealId = $btn.data('meal-id');
|
||||
var ing = $btn.data('ingredient');
|
||||
var $li = $btn.closest('li');
|
||||
var storeId = $li.find('.ingredient-store').val() || '';
|
||||
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
|
||||
.then(function () {
|
||||
window.alert('Added to grocery list.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not add');
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnMealWeekApply').on('click', function () {
|
||||
var ws = ($('#meal_week_start').val() || '').trim();
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Change the planning week? This clears the grid for the new week.')) {
|
||||
return;
|
||||
}
|
||||
postJson('/meal_plan_set_week.php', { weekStart: ws })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#mealWeekFeedback'), 'danger', err.message || 'Could not set week');
|
||||
$('#mealWeekFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
var slotModalEl = document.getElementById('mealSlotModal');
|
||||
var slotModal = null;
|
||||
if (slotModalEl && typeof bootstrap !== 'undefined') {
|
||||
slotModal = new bootstrap.Modal(slotModalEl);
|
||||
}
|
||||
|
||||
function renderSlotPickerList() {
|
||||
var lib = readMealsLibrary();
|
||||
var filter = ($('#slotPickerFilter').val() || '').toLowerCase();
|
||||
var $ul = $('#slotPickerList');
|
||||
$ul.empty();
|
||||
lib.forEach(function (m) {
|
||||
if (filter) {
|
||||
var tags = (m.tags || []).map(function (t) { return String(t).toLowerCase(); });
|
||||
if (tags.indexOf(filter) === -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
var $li = $('<li class="list-group-item list-group-item-action" role="button"></li>')
|
||||
.text(m.title || m.id)
|
||||
.attr('data-meal-id', m.id);
|
||||
$ul.append($li);
|
||||
});
|
||||
if ($ul.children().length === 0) {
|
||||
$ul.append('<li class="list-group-item text-muted">No meals match this filter.</li>');
|
||||
}
|
||||
}
|
||||
|
||||
$(document).on('click', '.btn-open-slot-picker', function () {
|
||||
var day = $(this).data('day');
|
||||
var mealType = $(this).data('meal-type');
|
||||
$('#slotPickerDay').val(String(day));
|
||||
$('#slotPickerType').val(String(mealType));
|
||||
if (mealType) {
|
||||
$('#slotPickerFilter').val(String(mealType));
|
||||
}
|
||||
renderSlotPickerList();
|
||||
if (slotModal) {
|
||||
slotModal.show();
|
||||
}
|
||||
});
|
||||
|
||||
$('#slotPickerFilter').on('change', renderSlotPickerList);
|
||||
|
||||
$(document).on('click', '#slotPickerList li[data-meal-id]', function () {
|
||||
var mealId = $(this).attr('data-meal-id');
|
||||
if (!mealId || !weekStart) {
|
||||
return;
|
||||
}
|
||||
var day = parseInt($('#slotPickerDay').val(), 10);
|
||||
var mealType = $('#slotPickerType').val();
|
||||
var pushGrocery = $('#slotPickerPushGrocery').is(':checked');
|
||||
postJson('/meal_plan_set_slot.php', {
|
||||
weekStart: weekStart,
|
||||
day: day,
|
||||
mealType: mealType,
|
||||
mealId: String(mealId),
|
||||
pushGrocery: pushGrocery
|
||||
})
|
||||
.then(function () {
|
||||
hideMealSlotModal();
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not assign meal');
|
||||
});
|
||||
});
|
||||
|
||||
$('#slotPickerClear').on('click', function () {
|
||||
if (!weekStart) {
|
||||
return;
|
||||
}
|
||||
var day = parseInt($('#slotPickerDay').val(), 10);
|
||||
var mealType = $('#slotPickerType').val();
|
||||
postJson('/meal_plan_set_slot.php', {
|
||||
weekStart: weekStart,
|
||||
day: day,
|
||||
mealType: mealType,
|
||||
mealId: '',
|
||||
pushGrocery: false
|
||||
})
|
||||
.then(function () {
|
||||
hideMealSlotModal();
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not clear slot');
|
||||
});
|
||||
});
|
||||
|
||||
$('#mealSaveForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var tags = [];
|
||||
$('.meal-tag-cb:checked').each(function () {
|
||||
tags.push($(this).val());
|
||||
});
|
||||
var ingRaw = $('#meal_ingredients').val() || '';
|
||||
var ingredients = ingRaw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
var payload = {
|
||||
id: ($('#meal_id').val() || '').trim(),
|
||||
title: ($('#meal_title').val() || '').trim(),
|
||||
image: $('#meal_image').val(),
|
||||
description: $('#meal_description').val(),
|
||||
directions: $('#meal_directions').val(),
|
||||
lists: mealListPayloadFromForm(),
|
||||
tags: tags,
|
||||
ingredients: ingredients,
|
||||
items: mealGroceryRowsPayload()
|
||||
};
|
||||
postJson('/meal_save.php', payload)
|
||||
.then(function () {
|
||||
window.location.href = window.location.pathname + '?tab=meals';
|
||||
})
|
||||
.catch(function (err) {
|
||||
showAlert($('#mealSaveFeedback'), 'danger', err.message || 'Could not save');
|
||||
$('#mealSaveFeedback').removeClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnAddGroceryRow').on('click', function () {
|
||||
var $first = $('#mealGroceryRows .meal-grocery-row').first();
|
||||
if (!$first.length) {
|
||||
return;
|
||||
}
|
||||
var $clone = $first.clone();
|
||||
$clone.find('input').val('');
|
||||
$clone.find('.g-qty').val('1');
|
||||
$clone.find('select').prop('selectedIndex', 0);
|
||||
$('#mealGroceryRows').append($clone);
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-meal-delete', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Delete this meal from the library?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/meal_delete.php', { id: id })
|
||||
.then(function () {
|
||||
window.location.href = window.location.pathname + '?tab=meals';
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not delete');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindCurrencyPage() {
|
||||
var $form = $('#expenseForm');
|
||||
if (!$form.length) {
|
||||
return;
|
||||
}
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var payload = {
|
||||
title: $('#expense_title').val(),
|
||||
description: $('#expense_description').val(),
|
||||
date: $('#expense_date').val(),
|
||||
value: Number($('#expense_value').val()),
|
||||
assignee_id: $('#expense_assignee').val()
|
||||
};
|
||||
postJson('/expense_create.php', payload)
|
||||
.then(function () {
|
||||
var $fb = $('#expenseFormFeedback');
|
||||
showAlert($fb, 'success', 'Expense recorded. Reloading…');
|
||||
$fb.removeClass('d-none');
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var $fb = $('#expenseFormFeedback');
|
||||
showAlert($fb, 'danger', err.message || 'Could not record expense');
|
||||
$fb.removeClass('d-none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindPeopleTable() {
|
||||
$(document).on('click', '.btn-delete-person', function () {
|
||||
var id = $(this).data('id');
|
||||
if (!id || !window.confirm('Remove this person from the hub?')) {
|
||||
return;
|
||||
}
|
||||
postJson('/people_delete.php', { id: id })
|
||||
.then(function () {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not remove');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-reset-pin', function () {
|
||||
var id = $(this).data('id');
|
||||
var pin = window.prompt('Enter a new PIN (4+ characters) for this Head of household.') || '';
|
||||
if (pin.length < 4) {
|
||||
return;
|
||||
}
|
||||
postJson('/people_reset_pin.php', { targetPersonId: id, newPin: pin })
|
||||
.then(function () {
|
||||
window.alert('PIN updated.');
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert(err.message || 'Could not reset PIN');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$(function () {
|
||||
bindPersonaSwitcher();
|
||||
bindFirstHeadForm();
|
||||
bindAddPersonForm();
|
||||
bindFamilySettingsForm();
|
||||
bindPeopleTable();
|
||||
bindChoresPage();
|
||||
bindCurrencyPage();
|
||||
bindStoreManagement();
|
||||
bindGroceryPage();
|
||||
bindMealsPage();
|
||||
});
|
||||
})(jQuery);
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
$envFile = dirname(__DIR__) . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
if (PHP_SAPI === 'cli') {
|
||||
fwrite(STDERR, "Family Hub: .env file not found.\n");
|
||||
fwrite(STDERR, "Open install.php in your browser or copy env.example to .env and edit.\n");
|
||||
exit(1);
|
||||
}
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load environment variables
|
||||
Env::load();
|
||||
|
||||
@ -31,7 +42,10 @@ define('EXPORT_RETENTION_DAYS', (int)Env::get('EXPORT_RETENTION_DAYS', 30));
|
||||
$TABS = [
|
||||
'chores' => ['title' => 'Chores', 'icon' => 'tasks'],
|
||||
'groceries' => ['title' => 'Grocery List', 'icon' => 'shopping-cart'],
|
||||
'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils']
|
||||
'meals' => ['title' => 'Meal Plan', 'icon' => 'utensils'],
|
||||
'calendar' => ['title' => 'Calendar', 'icon' => 'calendar-days'],
|
||||
'currency' => ['title' => 'Currency', 'icon' => 'coins'],
|
||||
'settings' => ['title' => 'Family settings', 'icon' => 'cog'],
|
||||
];
|
||||
|
||||
// Load local configuration if exists
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
42
includes/api_bootstrap.php
Normal file
42
includes/api_bootstrap.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/utils.php';
|
||||
require_once __DIR__ . '/persona.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function readJsonBody(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
function sendJson(array $payload, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode($payload);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $people
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function requireActivePerson(array $people): array {
|
||||
$p = getActivePerson($people);
|
||||
if ($p === null) {
|
||||
sendJson(['success' => false, 'error' => 'Select who is using the hub first.'], 403);
|
||||
}
|
||||
return $p;
|
||||
}
|
||||
198
includes/chore_helpers.php
Normal file
198
includes/chore_helpers.php
Normal file
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/persona.php';
|
||||
|
||||
const CHORE_SCHEDULE_ONCE = 'once';
|
||||
const CHORE_SCHEDULE_RECURRING = 'recurring';
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizeChoresList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $people
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function legacyAssigneeNameToIds(string $name, array $people): array {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
$lower = mb_strtolower($name, 'UTF-8');
|
||||
$ids = [];
|
||||
foreach ($people as $p) {
|
||||
$pn = trim((string) ($p['name'] ?? ''));
|
||||
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) {
|
||||
$ids[] = (string) $p['id'];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $c
|
||||
* @param array<int, array<string, mixed>> $people
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function migrateLegacyChoreRow(array $c, array $people): array {
|
||||
if (isset($c['name']) && !isset($c['title'])) {
|
||||
$c['title'] = (string) $c['name'];
|
||||
}
|
||||
if (!isset($c['assignee_ids']) || !is_array($c['assignee_ids'])) {
|
||||
if (isset($c['assignee'])) {
|
||||
$c['assignee_ids'] = legacyAssigneeNameToIds((string) $c['assignee'], $people);
|
||||
} else {
|
||||
$c['assignee_ids'] = [];
|
||||
}
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($c['assignee_ids'] as $id) {
|
||||
if (is_string($id) && $id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
$c['assignee_ids'] = array_values(array_unique($ids));
|
||||
if (!isset($c['lists']) || !is_array($c['lists'])) {
|
||||
$c['lists'] = [];
|
||||
}
|
||||
$c['lists'] = normalizeChoreLists($c['lists']);
|
||||
if (!isset($c['description'])) {
|
||||
$c['description'] = '';
|
||||
}
|
||||
if (!isset($c['image'])) {
|
||||
$c['image'] = '';
|
||||
}
|
||||
if (!isset($c['author_id'])) {
|
||||
$c['author_id'] = '';
|
||||
}
|
||||
if (!isset($c['value'])) {
|
||||
$c['value'] = 0;
|
||||
}
|
||||
$c['value'] = is_numeric($c['value']) ? (float) $c['value'] : 0.0;
|
||||
if (!isset($c['due_date'])) {
|
||||
$c['due_date'] = '';
|
||||
}
|
||||
$c['due_date'] = is_string($c['due_date']) ? trim($c['due_date']) : '';
|
||||
if ($c['due_date'] !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $c['due_date'])) {
|
||||
$c['due_date'] = '';
|
||||
}
|
||||
$sched = $c['schedule'] ?? CHORE_SCHEDULE_ONCE;
|
||||
$c['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
|
||||
if (!isset($c['recurrence_days']) || !is_numeric($c['recurrence_days'])) {
|
||||
$c['recurrence_days'] = 7;
|
||||
}
|
||||
$c['recurrence_days'] = max(1, (int) $c['recurrence_days']);
|
||||
if (!isset($c['status'])) {
|
||||
$c['status'] = 'active';
|
||||
}
|
||||
$st = (string) $c['status'];
|
||||
$c['status'] = in_array($st, ['active', 'completed'], true) ? $st : 'active';
|
||||
if (!array_key_exists('pending_submission', $c)) {
|
||||
$c['pending_submission'] = null;
|
||||
}
|
||||
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
|
||||
$c['pending_submission'] = null;
|
||||
}
|
||||
return $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $lists
|
||||
* @return array<int, array{type: string, items: array<int, string>}>
|
||||
*/
|
||||
function normalizeChoreLists($lists): array {
|
||||
if (!is_array($lists)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($lists as $block) {
|
||||
if (!is_array($block)) {
|
||||
continue;
|
||||
}
|
||||
$type = isset($block['type']) ? (string) $block['type'] : 'checkbox';
|
||||
if (!in_array($type, ['ordered', 'unordered', 'checkbox'], true)) {
|
||||
$type = 'checkbox';
|
||||
}
|
||||
$items = $block['items'] ?? [];
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($items as $it) {
|
||||
$s = trim((string) $it);
|
||||
if ($s !== '') {
|
||||
$clean[] = $s;
|
||||
}
|
||||
}
|
||||
$out[] = ['type' => $type, 'items' => $clean];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $chores
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function migrateAllChores(array $chores, array $people): array {
|
||||
$out = [];
|
||||
foreach ($chores as $c) {
|
||||
$out[] = migrateLegacyChoreRow($c, $people);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $chores
|
||||
*/
|
||||
function findChoreById(array $chores, string $id): ?array {
|
||||
foreach ($chores as $c) {
|
||||
if (($c['id'] ?? '') === $id) {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $chores
|
||||
*/
|
||||
function findChoreIndexById(array $chores, string $id): ?int {
|
||||
foreach ($chores as $i => $c) {
|
||||
if (($c['id'] ?? '') === $id) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $assigneeIds
|
||||
* @param array<int, array<string, mixed>> $people
|
||||
*/
|
||||
function choreAssigneeIdsValid(array $assigneeIds, array $people): bool {
|
||||
$set = [];
|
||||
foreach ($people as $p) {
|
||||
if (!empty($p['id'])) {
|
||||
$set[(string) $p['id']] = true;
|
||||
}
|
||||
}
|
||||
foreach ($assigneeIds as $id) {
|
||||
if (!isset($set[$id])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -1,23 +1,65 @@
|
||||
<?php
|
||||
|
||||
function readJsonFile($filename) {
|
||||
$filepath = __DIR__ . '/../data/' . $filename;
|
||||
function getDataDirectory(): string {
|
||||
return __DIR__ . '/../data';
|
||||
}
|
||||
|
||||
function getDataFilePath(string $filename): string {
|
||||
return getDataDirectory() . '/' . $filename;
|
||||
}
|
||||
|
||||
function ensureDataDirectory(): void {
|
||||
$dataDir = getDataDirectory();
|
||||
if (!is_dir($dataDir)) {
|
||||
mkdir($dataDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(string $filename) {
|
||||
$filepath = getDataFilePath($filename);
|
||||
if (!file_exists($filepath)) {
|
||||
return [];
|
||||
}
|
||||
$content = file_get_contents($filepath);
|
||||
return json_decode($content, true) ?? [];
|
||||
}
|
||||
|
||||
function writeJsonFile($filename, $data) {
|
||||
$filepath = __DIR__ . '/../data/' . $filename;
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT);
|
||||
return file_put_contents($filepath, $json);
|
||||
}
|
||||
|
||||
function ensureDataDirectory() {
|
||||
$dataDir = __DIR__ . '/../data';
|
||||
if (!file_exists($dataDir)) {
|
||||
mkdir($dataDir, 0755, true);
|
||||
$fp = fopen($filepath, 'rb');
|
||||
if ($fp === false) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!flock($fp, LOCK_SH)) {
|
||||
fclose($fp);
|
||||
return [];
|
||||
}
|
||||
$content = stream_get_contents($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
if ($content === false || $content === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($content, true);
|
||||
return $decoded ?? [];
|
||||
}
|
||||
|
||||
function writeJsonFile(string $filename, $data): bool {
|
||||
ensureDataDirectory();
|
||||
$filepath = getDataFilePath($filename);
|
||||
$fp = fopen($filepath, 'c+');
|
||||
if ($fp === false) {
|
||||
return false;
|
||||
}
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
$written = fwrite($fp, $json);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return $written !== false;
|
||||
}
|
||||
|
||||
18
includes/expense_helpers.php
Normal file
18
includes/expense_helpers.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizeExpensesList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
31
includes/family_settings.php
Normal file
31
includes/family_settings.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
function defaultFamilySettings(): array {
|
||||
return [
|
||||
'currency_symbol' => '★',
|
||||
'currency_name' => 'Stars',
|
||||
'currency_permanence' => 'permanent',
|
||||
'timezone' => 'UTC',
|
||||
'week_starts_on' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
function loadFamilySettings(): array {
|
||||
$raw = readJsonFile('family_settings.json');
|
||||
if (!is_array($raw)) {
|
||||
return defaultFamilySettings();
|
||||
}
|
||||
return array_merge(defaultFamilySettings(), $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab label: symbol + name (e.g. "★ Stars").
|
||||
*/
|
||||
function currencyTabLabel(array $familySettings): string {
|
||||
$sym = trim((string) ($familySettings['currency_symbol'] ?? ''));
|
||||
$name = trim((string) ($familySettings['currency_name'] ?? ''));
|
||||
$label = trim($sym . ' ' . $name);
|
||||
return $label !== '' ? $label : 'Currency';
|
||||
}
|
||||
395
includes/grocery_helpers.php
Normal file
395
includes/grocery_helpers.php
Normal file
@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizeStoresList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizeCatalogList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
|
||||
$n = mb_strtolower(trim($name), 'UTF-8');
|
||||
$s = mb_strtolower(trim($size), 'UTF-8');
|
||||
return $storeId . '|' . $n . '|' . $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
function defaultGroceryListsShape(): array {
|
||||
return ['byStore' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
function normalizeGroceryLists($raw): array {
|
||||
$base = defaultGroceryListsShape();
|
||||
if (!is_array($raw)) {
|
||||
return $base;
|
||||
}
|
||||
if (isset($raw['byStore']) && is_array($raw['byStore'])) {
|
||||
$base['byStore'] = [];
|
||||
foreach ($raw['byStore'] as $storeId => $items) {
|
||||
$sid = (string) $storeId;
|
||||
if ($sid === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_array($items) || !array_is_list($items)) {
|
||||
$base['byStore'][$sid] = [];
|
||||
continue;
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($items as $it) {
|
||||
if (is_array($it) && !empty($it['id']) && is_string($it['id'])) {
|
||||
$clean[] = $it;
|
||||
}
|
||||
}
|
||||
$base['byStore'][$sid] = $clean;
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
*/
|
||||
function findStoreById(array $stores, string $id): ?array {
|
||||
foreach ($stores as $s) {
|
||||
if (($s['id'] ?? '') === $id) {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $catalog
|
||||
*/
|
||||
function findCatalogById(array $catalog, string $id): ?array {
|
||||
foreach ($catalog as $c) {
|
||||
if (($c['id'] ?? '') === $id) {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduped rows for picker: one per dedupeKey (first wins for label).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $catalog
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function groceryCatalogPickerOptions(array $catalog, ?string $storeId = null): array {
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($catalog as $row) {
|
||||
if ($storeId !== null && (string) ($row['storeId'] ?? '') !== $storeId) {
|
||||
continue;
|
||||
}
|
||||
$key = (string) ($row['dedupeKey'] ?? '');
|
||||
if ($key === '') {
|
||||
$key = groceryCatalogDedupeKey(
|
||||
(string) ($row['storeId'] ?? ''),
|
||||
(string) ($row['name'] ?? ''),
|
||||
(string) ($row['defaultSize'] ?? '')
|
||||
);
|
||||
}
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$out[] = $row;
|
||||
}
|
||||
usort($out, static function ($a, $b) {
|
||||
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy groceries.json flat list into stores + lists + empty catalog.
|
||||
*
|
||||
* @return array{stores: array<int, array<string, mixed>>, lists: array{byStore: array<string, array<int, array<string, mixed>>>}, migrated: bool}
|
||||
*/
|
||||
function migrateLegacyGroceriesIfNeeded(): array {
|
||||
$listsPath = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$hasNew = count($listsPath['byStore']) > 0;
|
||||
$legacy = readJsonFile('groceries.json');
|
||||
$legacyList = is_array($legacy) && array_is_list($legacy) ? $legacy : [];
|
||||
|
||||
if ($hasNew || count($legacyList) === 0) {
|
||||
return ['stores' => normalizeStoresList(readJsonFile('stores.json')), 'lists' => $listsPath, 'migrated' => false];
|
||||
}
|
||||
|
||||
$storeId = bin2hex(random_bytes(8));
|
||||
$stores = [[
|
||||
'id' => $storeId,
|
||||
'name' => 'General',
|
||||
'sort' => 0,
|
||||
]];
|
||||
|
||||
$items = [];
|
||||
foreach ($legacyList as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$cat = trim((string) ($row['category'] ?? ''));
|
||||
$desc = trim((string) ($row['description'] ?? ''));
|
||||
if ($cat !== '' && $desc !== '') {
|
||||
$desc = 'Category: ' . $cat . "\n" . $desc;
|
||||
} elseif ($cat !== '') {
|
||||
$desc = 'Category: ' . $cat;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'catalogId' => null,
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
'size' => '',
|
||||
'quantity' => (string) ($row['quantity'] ?? '1'),
|
||||
'price' => '',
|
||||
'image' => '',
|
||||
'status' => 'active',
|
||||
'purchasedAt' => null,
|
||||
'source' => 'manual',
|
||||
'recurringIntervalDays' => 0,
|
||||
'addedAt' => gmdate('c'),
|
||||
];
|
||||
}
|
||||
|
||||
$listsPath['byStore'][$storeId] = $items;
|
||||
|
||||
writeJsonFile('stores.json', $stores);
|
||||
writeJsonFile('grocery_lists.json', $listsPath);
|
||||
|
||||
return ['stores' => $stores, 'lists' => $listsPath, 'migrated' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create catalog row + list line (used by grocery_item_create and meal → grocery).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
|
||||
*/
|
||||
function groceryAppendShoppingLine(
|
||||
array $stores,
|
||||
string $storeId,
|
||||
string $name,
|
||||
string $description = '',
|
||||
string $size = '',
|
||||
string $quantity = '1',
|
||||
string $price = '',
|
||||
string $image = '',
|
||||
string $source = 'manual',
|
||||
int $recurringIntervalDays = 0,
|
||||
?string $mealId = null,
|
||||
?string $mealTitleMeta = null
|
||||
): array {
|
||||
if (findStoreById($stores, $storeId) === null) {
|
||||
return ['ok' => false, 'error' => 'Invalid store'];
|
||||
}
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'Name is required'];
|
||||
}
|
||||
$description = trim($description);
|
||||
$size = trim($size);
|
||||
$quantity = trim($quantity) !== '' ? trim($quantity) : '1';
|
||||
$price = trim($price);
|
||||
$image = trim($image);
|
||||
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
|
||||
$catalogId = null;
|
||||
foreach ($catalog as $i => $c) {
|
||||
if ((string) ($c['storeId'] ?? '') === $storeId && (string) ($c['dedupeKey'] ?? '') === $dedupeKey) {
|
||||
$catalogId = (string) $c['id'];
|
||||
$catalog[$i]['name'] = $name;
|
||||
$catalog[$i]['description'] = $description;
|
||||
$catalog[$i]['defaultSize'] = $size;
|
||||
$catalog[$i]['defaultImage'] = $image;
|
||||
$catalog[$i]['dedupeKey'] = $dedupeKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($catalogId === null) {
|
||||
$catalogId = bin2hex(random_bytes(8));
|
||||
$catalog[] = [
|
||||
'id' => $catalogId,
|
||||
'storeId' => $storeId,
|
||||
'dedupeKey' => $dedupeKey,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'defaultSize' => $size,
|
||||
'defaultImage' => $image,
|
||||
'lastPurchaseAt' => null,
|
||||
'recurringIntervalDays' => 0,
|
||||
'nextDueDate' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$status = 'active';
|
||||
if ($source === 'meal_plan' || $source === 'pending_review') {
|
||||
$status = 'pending_review';
|
||||
}
|
||||
$src = $source === 'pending_review' ? 'meal_plan' : $source;
|
||||
if (!in_array($src, ['manual', 'meal_plan'], true)) {
|
||||
$src = 'manual';
|
||||
}
|
||||
|
||||
$line = normalizeGroceryLineItem([
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'catalogId' => $catalogId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'size' => $size,
|
||||
'quantity' => $quantity,
|
||||
'price' => $price,
|
||||
'image' => $image,
|
||||
'status' => $status,
|
||||
'purchasedAt' => null,
|
||||
'source' => $src,
|
||||
'recurringIntervalDays' => max(0, $recurringIntervalDays),
|
||||
'addedAt' => gmdate('c'),
|
||||
]);
|
||||
|
||||
if ($mealId !== null && $mealId !== '') {
|
||||
$line['mealId'] = $mealId;
|
||||
}
|
||||
if ($mealTitleMeta !== null && $mealTitleMeta !== '') {
|
||||
$line['mealTitle'] = $mealTitleMeta;
|
||||
}
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
if (!isset($lists['byStore'][$storeId])) {
|
||||
$lists['byStore'][$storeId] = [];
|
||||
}
|
||||
$lists['byStore'][$storeId][] = $line;
|
||||
|
||||
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
|
||||
return ['ok' => false, 'error' => 'Failed to save catalog'];
|
||||
}
|
||||
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||
return ['ok' => false, 'error' => 'Failed to save grocery list'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'item' => $line];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
*/
|
||||
function groceryFirstStoreId(array $stores): string {
|
||||
if (count($stores) === 0) {
|
||||
return '';
|
||||
}
|
||||
$copy = $stores;
|
||||
usort($copy, static function ($a, $b) {
|
||||
$sa = (int) ($a['sort'] ?? 0);
|
||||
$sb = (int) ($b['sort'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb;
|
||||
}
|
||||
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
return (string) ($copy[0]['id'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeGroceryLineItem(array $item): array {
|
||||
if (empty($item['id']) || !is_string($item['id'])) {
|
||||
$item['id'] = bin2hex(random_bytes(8));
|
||||
}
|
||||
$item['name'] = trim((string) ($item['name'] ?? ''));
|
||||
$item['description'] = trim((string) ($item['description'] ?? ''));
|
||||
$item['size'] = trim((string) ($item['size'] ?? ''));
|
||||
$item['quantity'] = trim((string) ($item['quantity'] ?? '1'));
|
||||
$item['price'] = trim((string) ($item['price'] ?? ''));
|
||||
$item['image'] = trim((string) ($item['image'] ?? ''));
|
||||
$st = (string) ($item['status'] ?? 'active');
|
||||
$item['status'] = in_array($st, ['active', 'purchased', 'pending_review'], true) ? $st : 'active';
|
||||
$item['purchasedAt'] = isset($item['purchasedAt']) && is_string($item['purchasedAt']) ? $item['purchasedAt'] : null;
|
||||
$item['source'] = in_array($item['source'] ?? 'manual', ['manual', 'meal_plan', 'meal_detail'], true)
|
||||
? $item['source']
|
||||
: 'manual';
|
||||
$item['recurringIntervalDays'] = max(0, (int) ($item['recurringIntervalDays'] ?? 0));
|
||||
$item['catalogId'] = isset($item['catalogId']) && is_string($item['catalogId']) ? $item['catalogId'] : null;
|
||||
if (empty($item['addedAt'])) {
|
||||
$item['addedAt'] = gmdate('c');
|
||||
}
|
||||
if (!empty($item['mealId']) && is_string($item['mealId'])) {
|
||||
$item['mealId'] = trim($item['mealId']);
|
||||
} else {
|
||||
unset($item['mealId']);
|
||||
}
|
||||
if (isset($item['mealTitle']) && is_string($item['mealTitle'])) {
|
||||
$item['mealTitle'] = trim($item['mealTitle']);
|
||||
} else {
|
||||
unset($item['mealTitle']);
|
||||
}
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
|
||||
*/
|
||||
function groceryStoreHasItems(array $lists, string $storeId): bool {
|
||||
$items = $lists['byStore'][$storeId] ?? [];
|
||||
return is_array($items) && count($items) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are no stores yet, create a single "Home" store and empty list bucket.
|
||||
*/
|
||||
function ensureDefaultGroceryStore(): void {
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
if (count($stores) > 0) {
|
||||
return;
|
||||
}
|
||||
$id = bin2hex(random_bytes(8));
|
||||
writeJsonFile('stores.json', [['id' => $id, 'name' => 'Home', 'sort' => 0]]);
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
if (!isset($lists['byStore'][$id])) {
|
||||
$lists['byStore'][$id] = [];
|
||||
}
|
||||
writeJsonFile('grocery_lists.json', $lists);
|
||||
}
|
||||
@ -4,20 +4,93 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Family Hub</title>
|
||||
|
||||
<!-- Bootstrap CSS from CDN -->
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome icons from CDN -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom styles -->
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-primary text-white p-3">
|
||||
<body class="family-hub-body" style="--person-accent: <?= htmlspecialchars($favoriteColor ?? '#4a90e2', ENT_QUOTES, 'UTF-8') ?>;">
|
||||
<header class="app-header text-white p-3 mb-0">
|
||||
<div class="container">
|
||||
<h1>Family Hub</h1>
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-3">
|
||||
<h1 class="h3 mb-0">Family Hub</h1>
|
||||
<div class="d-flex flex-column align-items-start align-items-md-end gap-2 ms-md-auto">
|
||||
<?php
|
||||
$hdrSym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
|
||||
$hdrName = trim((string) ($familySettings['currency_name'] ?? ''));
|
||||
$hdrBal = 0.0;
|
||||
if ($activePerson !== null) {
|
||||
$hdrBal = is_numeric($activePerson['currency_balance'] ?? null)
|
||||
? (float) $activePerson['currency_balance']
|
||||
: 0.0;
|
||||
}
|
||||
?>
|
||||
<?php if ($activePerson !== null && count($people) > 0): ?>
|
||||
<div class="user-balance badge rounded-pill bg-light text-dark px-3 py-2 text-wrap text-start" title="Current balance for the selected profile">
|
||||
<span class="text-muted small">Balance</span><br>
|
||||
<strong class="fs-6"><?= htmlspecialchars(number_format($hdrBal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
<span class="ms-1"><?= htmlspecialchars($hdrSym, ENT_QUOTES, 'UTF-8') ?><?php if ($hdrName !== ''): ?><span class="text-muted small"> <?= htmlspecialchars($hdrName, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="persona-switcher d-flex flex-wrap gap-2 align-items-center" role="toolbar" aria-label="Who is using the hub">
|
||||
<?php if (count($people) === 0): ?>
|
||||
<span class="small opacity-75">Add people in Family settings.</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php
|
||||
$pid = $p['id'] ?? '';
|
||||
$pname = $p['name'] ?? 'Unknown';
|
||||
$isActive = $activePerson && ($activePerson['id'] ?? '') === $pid;
|
||||
$needsPin = personRequiresPinToActivate($p) ? '1' : '0';
|
||||
$roleLabel = $p['role'] ?? '';
|
||||
?>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm persona-chip <?= $isActive ? 'btn-light active' : 'btn-outline-light' ?>"
|
||||
data-person-id="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-needs-pin="<?= $needsPin ?>"
|
||||
data-person-name="<?= htmlspecialchars($pname, ENT_QUOTES, 'UTF-8') ?>"
|
||||
>
|
||||
<?= sanitizeInput($pname) ?>
|
||||
<?php if ($roleLabel === ROLE_HEAD): ?>
|
||||
<span class="visually-hidden">(Head of household)</span>
|
||||
<i class="fa fa-house-chimney-user ms-1" aria-hidden="true"></i>
|
||||
<?php elseif ($roleLabel === ROLE_ADULT): ?>
|
||||
<i class="fa fa-person ms-1" aria-hidden="true"></i>
|
||||
<?php elseif ($roleLabel === ROLE_CHILD): ?>
|
||||
<i class="fa fa-child ms-1" aria-hidden="true"></i>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container py-4">
|
||||
|
||||
<div class="modal fade" id="hohPinModal" tabindex="-1" aria-labelledby="hohPinModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="hohPinModalLabel">Head of household PIN</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted mb-2" id="hohPinModalPrompt">Enter PIN to switch to this profile.</p>
|
||||
<label for="hohPinInput" class="form-label">PIN</label>
|
||||
<input type="password" class="form-control form-control-lg" id="hohPinInput" autocomplete="current-password" minlength="4">
|
||||
<div class="invalid-feedback d-block d-none" id="hohPinError"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="hohPinSubmit">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.familyHubApiBase = <?= json_encode($familyHubApiBase, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP) ?>;
|
||||
</script>
|
||||
<main class="container py-4">
|
||||
|
||||
298
includes/meal_helpers.php
Normal file
298
includes/meal_helpers.php
Normal file
@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/chore_helpers.php';
|
||||
require_once __DIR__ . '/grocery_helpers.php';
|
||||
|
||||
const MEAL_SLOT_BREAKFAST = 'breakfast';
|
||||
const MEAL_SLOT_LUNCH = 'lunch';
|
||||
const MEAL_SLOT_DINNER = 'dinner';
|
||||
|
||||
/** @return array<int, string> */
|
||||
function mealSlotTypes(): array {
|
||||
return [MEAL_SLOT_BREAKFAST, MEAL_SLOT_LUNCH, MEAL_SLOT_DINNER];
|
||||
}
|
||||
|
||||
function mealCurrentWeekStart(): string {
|
||||
try {
|
||||
$d = new DateTimeImmutable('monday this week');
|
||||
return $d->format('Y-m-d');
|
||||
} catch (Exception $e) {
|
||||
return gmdate('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string|null>>
|
||||
*/
|
||||
function mealDefaultEmptySlots(): array {
|
||||
$slots = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$key = (string) $i;
|
||||
$slots[$key] = [
|
||||
MEAL_SLOT_BREAKFAST => null,
|
||||
MEAL_SLOT_LUNCH => null,
|
||||
MEAL_SLOT_DINNER => null,
|
||||
];
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array{weekStart: string, slots: array<string, array<string, string|null>>}
|
||||
*/
|
||||
function normalizeMealPlan($raw): array {
|
||||
$base = [
|
||||
'weekStart' => mealCurrentWeekStart(),
|
||||
'slots' => mealDefaultEmptySlots(),
|
||||
];
|
||||
if (!is_array($raw)) {
|
||||
return $base;
|
||||
}
|
||||
$ws = isset($raw['weekStart']) ? trim((string) $raw['weekStart']) : '';
|
||||
if ($ws !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $ws)) {
|
||||
$base['weekStart'] = $ws;
|
||||
}
|
||||
$slotsIn = $raw['slots'] ?? [];
|
||||
if (is_array($slotsIn)) {
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$key = (string) $i;
|
||||
$day = $slotsIn[$key] ?? $slotsIn[$i] ?? [];
|
||||
if (!is_array($day)) {
|
||||
continue;
|
||||
}
|
||||
foreach (mealSlotTypes() as $mt) {
|
||||
$mid = $day[$mt] ?? null;
|
||||
if ($mid === null || $mid === '') {
|
||||
$base['slots'][$key][$mt] = null;
|
||||
} else {
|
||||
$base['slots'][$key][$mt] = is_string($mid) ? $mid : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
function loadMealPlan(): array {
|
||||
return normalizeMealPlan(readJsonFile('meal_plans.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizeMealsList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $m
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeMealRow(array $m): array {
|
||||
if (isset($m['name']) && !isset($m['title'])) {
|
||||
$m['title'] = trim((string) $m['name']);
|
||||
}
|
||||
$m['title'] = trim((string) ($m['title'] ?? ''));
|
||||
$m['image'] = trim((string) ($m['image'] ?? ''));
|
||||
$m['description'] = trim((string) ($m['description'] ?? ''));
|
||||
$m['directions'] = trim((string) ($m['directions'] ?? ''));
|
||||
if (!isset($m['lists']) || !is_array($m['lists'])) {
|
||||
$m['lists'] = [];
|
||||
}
|
||||
$m['lists'] = normalizeChoreLists($m['lists']);
|
||||
$tags = $m['tags'] ?? [];
|
||||
if (!is_array($tags)) {
|
||||
$tags = [];
|
||||
}
|
||||
$cleanTags = [];
|
||||
foreach ($tags as $t) {
|
||||
$t = strtolower(trim((string) $t));
|
||||
if (in_array($t, ['breakfast', 'lunch', 'dinner'], true)) {
|
||||
$cleanTags[] = $t;
|
||||
}
|
||||
}
|
||||
if (isset($m['type']) && is_string($m['type']) && count($cleanTags) === 0) {
|
||||
$tt = strtolower(trim($m['type']));
|
||||
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
|
||||
$cleanTags[] = $tt;
|
||||
}
|
||||
}
|
||||
$m['tags'] = array_values(array_unique($cleanTags));
|
||||
$ing = $m['ingredients'] ?? [];
|
||||
if (!is_array($ing)) {
|
||||
$ing = [];
|
||||
}
|
||||
$ingOut = [];
|
||||
foreach ($ing as $line) {
|
||||
$s = trim((string) $line);
|
||||
if ($s !== '') {
|
||||
$ingOut[] = $s;
|
||||
}
|
||||
}
|
||||
$m['ingredients'] = $ingOut;
|
||||
$items = $m['items'] ?? [];
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
$itemOut = [];
|
||||
foreach ($items as $it) {
|
||||
if (!is_array($it)) {
|
||||
continue;
|
||||
}
|
||||
$nm = trim((string) ($it['name'] ?? ''));
|
||||
if ($nm === '') {
|
||||
continue;
|
||||
}
|
||||
$itemOut[] = [
|
||||
'name' => $nm,
|
||||
'storeId' => trim((string) ($it['storeId'] ?? '')),
|
||||
'description' => trim((string) ($it['description'] ?? '')),
|
||||
'size' => trim((string) ($it['size'] ?? '')),
|
||||
'quantity' => trim((string) ($it['quantity'] ?? '1')) ?: '1',
|
||||
'price' => trim((string) ($it['price'] ?? '')),
|
||||
'image' => trim((string) ($it['image'] ?? '')),
|
||||
];
|
||||
}
|
||||
$m['items'] = $itemOut;
|
||||
if (!isset($m['author_id'])) {
|
||||
$m['author_id'] = '';
|
||||
}
|
||||
return $m;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $meals
|
||||
*/
|
||||
function findMealById(array $meals, string $id): ?array {
|
||||
foreach ($meals as $m) {
|
||||
if (($m['id'] ?? '') === $id) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $meals
|
||||
*/
|
||||
function findMealIndexById(array $meals, string $id): ?int {
|
||||
foreach ($meals as $i => $m) {
|
||||
if (($m['id'] ?? '') === $id) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy meals (name, date, type) into library format.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $meals
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function migrateLegacyMealsList(array $meals): array {
|
||||
$out = [];
|
||||
foreach ($meals as $m) {
|
||||
if (!is_array($m)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($m['title']) && isset($m['tags'])) {
|
||||
$out[] = normalizeMealRow($m);
|
||||
continue;
|
||||
}
|
||||
$title = trim((string) ($m['title'] ?? $m['name'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$tags = [];
|
||||
if (!empty($m['type'])) {
|
||||
$tt = strtolower(trim((string) $m['type']));
|
||||
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
|
||||
$tags[] = $tt;
|
||||
}
|
||||
}
|
||||
$out[] = normalizeMealRow([
|
||||
'id' => !empty($m['id']) && is_string($m['id']) ? $m['id'] : bin2hex(random_bytes(8)),
|
||||
'title' => $title,
|
||||
'tags' => $tags,
|
||||
'description' => isset($m['date']) ? 'Legacy date: ' . (string) $m['date'] : '',
|
||||
'image' => '',
|
||||
'directions' => '',
|
||||
'lists' => [],
|
||||
'ingredients' => [],
|
||||
'items' => [],
|
||||
'author_id' => '',
|
||||
]);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push meal shopping items to grocery lists as pending_review.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
* @return int number of lines added
|
||||
*/
|
||||
function pushMealItemsToGrocery(array $meal, array $stores): int {
|
||||
$items = $meal['items'] ?? [];
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
return 0;
|
||||
}
|
||||
$mealId = (string) ($meal['id'] ?? '');
|
||||
$mealTitle = (string) ($meal['title'] ?? '');
|
||||
$defaultStore = groceryFirstStoreId($stores);
|
||||
if ($defaultStore === '') {
|
||||
return 0;
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($items as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$sid = trim((string) ($row['storeId'] ?? ''));
|
||||
if ($sid === '' || findStoreById($stores, $sid) === null) {
|
||||
$sid = $defaultStore;
|
||||
}
|
||||
$res = groceryAppendShoppingLine(
|
||||
$stores,
|
||||
$sid,
|
||||
$name,
|
||||
trim((string) ($row['description'] ?? '')),
|
||||
trim((string) ($row['size'] ?? '')),
|
||||
trim((string) ($row['quantity'] ?? '1')) ?: '1',
|
||||
trim((string) ($row['price'] ?? '')),
|
||||
trim((string) ($row['image'] ?? '')),
|
||||
'meal_plan',
|
||||
0,
|
||||
$mealId !== '' ? $mealId : null,
|
||||
$mealTitle !== '' ? $mealTitle : null
|
||||
);
|
||||
if ($res['ok']) {
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
return $added;
|
||||
}
|
||||
|
||||
function mealDayShortLabel(string $weekStart, int $offset): string {
|
||||
$ts = strtotime($weekStart . ' UTC +' . $offset . ' days');
|
||||
if ($ts === false) {
|
||||
return (string) $offset;
|
||||
}
|
||||
return gmdate('D n/j', $ts);
|
||||
}
|
||||
87
includes/persona.php
Normal file
87
includes/persona.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
const ROLE_HEAD = 'head_of_household';
|
||||
const ROLE_ADULT = 'adult';
|
||||
const ROLE_CHILD = 'child';
|
||||
|
||||
const SESSION_ACTIVE_PERSON = 'active_person_id';
|
||||
const SESSION_HOH_VERIFIED = 'hoh_verified';
|
||||
|
||||
function startFamilyHubSession(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function getActivePersonId(): ?string {
|
||||
$id = $_SESSION[SESSION_ACTIVE_PERSON] ?? null;
|
||||
return is_string($id) && $id !== '' ? $id : null;
|
||||
}
|
||||
|
||||
function setSessionPerson(string $personId, bool $hohVerified): void {
|
||||
$_SESSION[SESSION_ACTIVE_PERSON] = $personId;
|
||||
$_SESSION[SESSION_HOH_VERIFIED] = $hohVerified;
|
||||
}
|
||||
|
||||
function clearPersonaSession(): void {
|
||||
unset($_SESSION[SESSION_ACTIVE_PERSON], $_SESSION[SESSION_HOH_VERIFIED]);
|
||||
}
|
||||
|
||||
function isHohVerified(): bool {
|
||||
return !empty($_SESSION[SESSION_HOH_VERIFIED]);
|
||||
}
|
||||
|
||||
function findPersonById(array $people, string $id): ?array {
|
||||
foreach ($people as $p) {
|
||||
if (($p['id'] ?? '') === $id) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActivePerson(array $people): ?array {
|
||||
$id = getActivePersonId();
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
return findPersonById($people, $id);
|
||||
}
|
||||
|
||||
function personRequiresPinToActivate(?array $person): bool {
|
||||
if ($person === null) {
|
||||
return false;
|
||||
}
|
||||
return ($person['role'] ?? '') === ROLE_HEAD && !empty($person['pin_hash']);
|
||||
}
|
||||
|
||||
function assertHoHCanManagePeople(array $people): void {
|
||||
if (count($people) === 0) {
|
||||
return;
|
||||
}
|
||||
$active = getActivePerson($people);
|
||||
if ($active === null || ($active['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Head of household verification required.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function normalizePeopleList($raw): array {
|
||||
if (!is_array($raw) || !array_is_list($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $row) {
|
||||
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@ -20,4 +20,16 @@ function ensureExportDirectory() {
|
||||
if (!file_exists(EXPORT_DESTINATION)) {
|
||||
mkdir(EXPORT_DESTINATION, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base path prefix for JSON API under this app (e.g. /familyHub/api).
|
||||
*/
|
||||
function familyHubWebApiBase(): string {
|
||||
$sd = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
|
||||
$sd = rtrim(str_replace('\\', '/', $sd), '/');
|
||||
if ($sd === '' || $sd === '/') {
|
||||
return '/api';
|
||||
}
|
||||
return $sd . '/api';
|
||||
}
|
||||
|
||||
44
index.php
44
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';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<?php foreach ($TABS as $tabId => $tab): ?>
|
||||
<a href="?tab=<?= $tabId ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>">
|
||||
<i class="fa fa-<?= $tab['icon'] ?>"></i> <?= $tab['title'] ?>
|
||||
<a href="?tab=<?= htmlspecialchars($tabId, ENT_QUOTES, 'UTF-8') ?>" class="tab <?= $activeTab === $tabId ? 'active' : '' ?>">
|
||||
<i class="fa fa-<?= htmlspecialchars($tab['icon'], ENT_QUOTES, 'UTF-8') ?>"></i> <?= htmlspecialchars($tab['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content">
|
||||
<?php include "tabs/$activeTab.php"; ?>
|
||||
<?php include 'tabs/' . $activeTab . '.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
|
||||
227
install.php
Normal file
227
install.php
Normal file
@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$rootPath = __DIR__;
|
||||
$envPath = $rootPath . '/.env';
|
||||
$templatePath = $rootPath . '/env.example';
|
||||
|
||||
/**
|
||||
* Serialize a value for a dotenv-style line; quote when needed.
|
||||
*/
|
||||
function installFormatEnvValue(string $value): string {
|
||||
if ($value === '' || preg_match('/[\s#=\\\\"]/', $value) === 1) {
|
||||
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
|
||||
return '"' . $escaped . '"';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip matching outer quotes from env.example defaults for display.
|
||||
*/
|
||||
function installUnquoteDefault(string $value): string {
|
||||
$value = trim($value);
|
||||
$len = strlen($value);
|
||||
if ($len >= 2) {
|
||||
if ($value[0] === '"' && $value[$len - 1] === '"') {
|
||||
return substr($value, 1, -1);
|
||||
}
|
||||
if ($value[0] === "'" && $value[$len - 1] === "'") {
|
||||
return substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{section: string|null, key: string, default: string}>
|
||||
*/
|
||||
function installParseTemplateForForm(string $templatePath): array {
|
||||
$raw = file($templatePath, FILE_IGNORE_NEW_LINES);
|
||||
if ($raw === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
$section = null;
|
||||
foreach ($raw as $line) {
|
||||
$trim = trim($line);
|
||||
if ($trim === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($trim, '#') === 0) {
|
||||
$section = trim(substr($trim, 1));
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
[$name, $val] = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$entries[] = [
|
||||
'section' => $section,
|
||||
'key' => $name,
|
||||
'default' => installUnquoteDefault($val),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
function installWriteEnvFromTemplate(string $templatePath, string $envPath, array $postEnv): bool {
|
||||
$raw = file($templatePath, FILE_IGNORE_NEW_LINES);
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($raw as $line) {
|
||||
$trim = trim($line);
|
||||
if ($trim === '' || strpos($trim, '#') === 0) {
|
||||
$out[] = $line;
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') === false) {
|
||||
$out[] = $line;
|
||||
continue;
|
||||
}
|
||||
[$name, $templateVal] = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
if (array_key_exists($name, $postEnv)) {
|
||||
$submitted = (string) $postEnv[$name];
|
||||
} else {
|
||||
$submitted = installUnquoteDefault($templateVal);
|
||||
}
|
||||
$out[] = $name . '=' . installFormatEnvValue($submitted);
|
||||
}
|
||||
|
||||
$bytes = file_put_contents($envPath, implode("\n", $out) . "\n", LOCK_EX);
|
||||
if ($bytes === false) {
|
||||
return false;
|
||||
}
|
||||
@chmod($envPath, 0600);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Already configured
|
||||
if (file_exists($envPath)) {
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Family Hub — Setup</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-5">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<h1 class="h3 mb-3">Family Hub</h1>
|
||||
<p class="text-muted">This application is already configured (<code>.env</code> exists).</p>
|
||||
<a href="index.php" class="btn btn-primary">Go to Family Hub</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_readable($templatePath)) {
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
http_response_code(500);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Family Hub — Setup error</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-5">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<h1 class="h3 mb-3 text-danger">Setup cannot continue</h1>
|
||||
<p>The template file <code>env.example</code> is missing or not readable. Restore it from the repository.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$postEnv = $_POST['env'] ?? [];
|
||||
if (!is_array($postEnv)) {
|
||||
$postEnv = [];
|
||||
}
|
||||
if (installWriteEnvFromTemplate($templatePath, $envPath, $postEnv)) {
|
||||
header('Location: index.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
$writeError = true;
|
||||
} else {
|
||||
$writeError = false;
|
||||
}
|
||||
|
||||
$entries = installParseTemplateForForm($templatePath);
|
||||
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Family Hub — Environment setup</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<header class="mb-4">
|
||||
<h1 class="h3"><i class="fa fa-sliders text-primary me-2"></i>Environment setup</h1>
|
||||
<p class="text-muted mb-0">Values are generated from <code>env.example</code>. Fill in your credentials and save.</p>
|
||||
</header>
|
||||
|
||||
<?php if (!empty($writeError)): ?>
|
||||
<div class="alert alert-danger">Could not write <code>.env</code>. Check that the app directory is writable by the web server.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<?php
|
||||
$lastSection = null;
|
||||
foreach ($entries as $row):
|
||||
if ($row['section'] !== $lastSection && $row['section'] !== null && $row['section'] !== ''):
|
||||
$lastSection = $row['section'];
|
||||
?>
|
||||
<h2 class="h6 text-uppercase text-secondary border-bottom pb-2 mt-4 first-section"><?= htmlspecialchars($row['section'], ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<?php
|
||||
elseif ($row['section'] !== $lastSection):
|
||||
$lastSection = $row['section'];
|
||||
endif;
|
||||
$key = $row['key'];
|
||||
$val = $row['default'];
|
||||
?>
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-monospace small mb-1" for="env_<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="text" class="form-control font-monospace" name="env[<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>]" id="env_<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>" value="<?= htmlspecialchars($val, ENT_QUOTES, 'UTF-8') ?>" autocomplete="off">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top-0 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save and continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<style>.first-section:first-child { margin-top: 0 !important; }</style>
|
||||
</body>
|
||||
</html>
|
||||
119
readme.md
119
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
|
||||
├── AGENTS.md # Agent / contributor notes
|
||||
└── export.php # `?type=` JSON export (same types as daily script)
|
||||
|
||||
@ -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) {
|
||||
|
||||
71
scripts/grocery_recurring.php
Normal file
71
scripts/grocery_recurring.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/**
|
||||
* Add recurring catalog items back to the active list when nextDueDate is today or past.
|
||||
* Run from cron after daily_export or on its own.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
if (!file_exists(dirname(__DIR__) . '/.env')) {
|
||||
fwrite(STDERR, "Family Hub: .env missing.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$today = gmdate('Y-m-d');
|
||||
$added = 0;
|
||||
|
||||
foreach ($catalog as $ci => $c) {
|
||||
$next = isset($c['nextDueDate']) ? trim((string) $c['nextDueDate']) : '';
|
||||
$interval = max(0, (int) ($c['recurringIntervalDays'] ?? 0));
|
||||
if ($next === '' || $interval < 1) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp($next, $today) > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeId = (string) ($c['storeId'] ?? '');
|
||||
if ($storeId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($c['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = normalizeGroceryLineItem([
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'catalogId' => (string) ($c['id'] ?? ''),
|
||||
'name' => $name,
|
||||
'description' => trim((string) ($c['description'] ?? '')),
|
||||
'size' => trim((string) ($c['defaultSize'] ?? '')),
|
||||
'quantity' => '1',
|
||||
'price' => '',
|
||||
'image' => trim((string) ($c['defaultImage'] ?? '')),
|
||||
'status' => 'active',
|
||||
'purchasedAt' => null,
|
||||
'source' => 'manual',
|
||||
'recurringIntervalDays' => $interval,
|
||||
'addedAt' => gmdate('c'),
|
||||
]);
|
||||
|
||||
if (!isset($lists['byStore'][$storeId])) {
|
||||
$lists['byStore'][$storeId] = [];
|
||||
}
|
||||
$lists['byStore'][$storeId][] = $line;
|
||||
|
||||
$catalog[$ci]['nextDueDate'] = gmdate('Y-m-d', strtotime('+' . $interval . ' days'));
|
||||
$added++;
|
||||
}
|
||||
|
||||
if ($added > 0) {
|
||||
writeJsonFile('grocery_catalog.json', $catalog);
|
||||
writeJsonFile('grocery_lists.json', $lists);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, 'grocery_recurring: added ' . $added . " item(s).\n");
|
||||
67
tabs/calendar.php
Normal file
67
tabs/calendar.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
$rawEmbed = defined('GOOGLE_CALENDAR_EMBED_CODE') ? trim((string) GOOGLE_CALENDAR_EMBED_CODE) : '';
|
||||
$calId = defined('GOOGLE_CALENDAR_ID') ? trim((string) GOOGLE_CALENDAR_ID) : '';
|
||||
|
||||
/**
|
||||
* @return string|null HTTPS embed URL restricted to Google Calendar hosts
|
||||
*/
|
||||
function familyHub_google_calendar_embed_url(string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$allowed = static function (string $u): bool {
|
||||
$p = parse_url($u);
|
||||
if ($p === false || empty($p['scheme']) || empty($p['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (strtolower($p['scheme']) !== 'https') {
|
||||
return false;
|
||||
}
|
||||
$h = strtolower($p['host']);
|
||||
if ($h === 'calendar.google.com') {
|
||||
return true;
|
||||
}
|
||||
if ($h === 'www.google.com' && isset($p['path']) && str_starts_with($p['path'], '/calendar')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if ($allowed($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (preg_match('#<iframe\s[^>]*\ssrc\s*=\s*["\']([^"\']+)["\']#i', $raw, $m)) {
|
||||
$u = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return $allowed($u) ? $u : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$embedUrl = familyHub_google_calendar_embed_url($rawEmbed);
|
||||
$fromIdOnly = false;
|
||||
if ($embedUrl === null && $calId !== '' && $calId !== 'your_calendar_id_here') {
|
||||
$embedUrl = 'https://calendar.google.com/calendar/embed?src=' . rawurlencode($calId);
|
||||
$fromIdOnly = true;
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="calendar" class="tab-content">
|
||||
<h2 class="mb-2">Calendar</h2>
|
||||
<?php if ($embedUrl === null): ?>
|
||||
<p class="text-muted">Connect a shared family calendar by setting <strong>GOOGLE_CALENDAR_EMBED_CODE</strong> (embed URL or full <code><iframe></code> from Google Calendar) or <strong>GOOGLE_CALENDAR_ID</strong> in <code>.env</code>. See <a href="https://calendar.google.com/">Google Calendar</a> → calendar menu → <strong>Settings and sharing</strong> → <strong>Integrate calendar</strong>.</p>
|
||||
<?php else: ?>
|
||||
<?php if ($fromIdOnly): ?>
|
||||
<p class="small text-muted mb-2">Showing calendar from <code>GOOGLE_CALENDAR_ID</code>. For more control (view, height), paste the embed URL or iframe into <code>GOOGLE_CALENDAR_EMBED_CODE</code> instead.</p>
|
||||
<?php endif; ?>
|
||||
<div class="calendar-embed rounded border overflow-hidden bg-white">
|
||||
<iframe
|
||||
class="w-100 border-0 d-block"
|
||||
style="height: 70vh; min-height: 560px;"
|
||||
src="<?= htmlspecialchars($embedUrl, ENT_QUOTES, 'UTF-8') ?>"
|
||||
loading="lazy"
|
||||
title="Family calendar"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
316
tabs/chores.php
316
tabs/chores.php
@ -1,25 +1,313 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
$chores = readJsonFile('chores.json');
|
||||
$rawChores = readJsonFile('chores.json');
|
||||
$chores = migrateAllChores(normalizeChoresList($rawChores), $people);
|
||||
|
||||
$nameById = [];
|
||||
foreach ($people as $p) {
|
||||
if (!empty($p['id'])) {
|
||||
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$currencySymbol = $familySettings['currency_symbol'] ?? '★';
|
||||
|
||||
$canReview = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$actorId = $activePerson['id'] ?? '';
|
||||
$editId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
|
||||
$editChore = $editId !== '' ? findChoreById($chores, $editId) : null;
|
||||
$editNotFound = $editId !== '' && $editChore === null;
|
||||
|
||||
$editChoreAllowed = $editChore !== null && $activePerson !== null && (
|
||||
(string) ($editChore['author_id'] ?? '') === (string) $actorId
|
||||
|| $canReview
|
||||
);
|
||||
|
||||
$showChoreForm = $activePerson !== null
|
||||
&& !$editNotFound
|
||||
&& (
|
||||
($editChore !== null && $editChoreAllowed)
|
||||
|| ($editChore === null && $canReview)
|
||||
);
|
||||
|
||||
$pendingForReview = [];
|
||||
$activeChores = [];
|
||||
$completedChores = [];
|
||||
foreach ($chores as $c) {
|
||||
$st = (string) ($c['status'] ?? 'active');
|
||||
$pend = $c['pending_submission'] ?? null;
|
||||
if ($st === 'active' && is_array($pend) && $canReview) {
|
||||
$pendingForReview[] = $c;
|
||||
}
|
||||
if ($st === 'completed') {
|
||||
$completedChores[] = $c;
|
||||
} elseif ($st === 'active') {
|
||||
$activeChores[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
$prefillList = ['type' => 'checkbox', 'items' => []];
|
||||
if ($editChore) {
|
||||
$lists = $editChore['lists'] ?? [];
|
||||
if (is_array($lists) && count($lists) > 0) {
|
||||
$b = $lists[0];
|
||||
$t = $b['type'] ?? 'checkbox';
|
||||
$prefillList['type'] = in_array($t, ['ordered', 'unordered', 'checkbox'], true) ? $t : 'checkbox';
|
||||
$items = $b['items'] ?? [];
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $it) {
|
||||
$prefillList['items'][] = (string) $it;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="chores" class="tab-content">
|
||||
<h2>Chores</h2>
|
||||
<div class="chores-list">
|
||||
<?php if (empty($chores)): ?>
|
||||
<p>No chores added yet.</p>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($chores as $chore): ?>
|
||||
<li>
|
||||
<span class="chore-name"><?php echo sanitizeInput($chore['name']); ?></span>
|
||||
<span class="chore-assignee"><?php echo sanitizeInput($chore['assignee']); ?></span>
|
||||
<span class="chore-due-date"><?php echo formatDate($chore['due_date']); ?></span>
|
||||
<h2 class="mb-2">Chores</h2>
|
||||
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<div class="alert alert-info small mb-3 chore-howto">
|
||||
<strong>How to finish a chore:</strong> The checklist bullets are reminders only (not clickable).
|
||||
If this chore is assigned to you, use <strong>Submit for approval</strong> on the card when the work is done.
|
||||
A Head of household then <strong>approves</strong> it so the reward is added to balances.
|
||||
<span class="d-block mt-1 text-muted">Use the <strong>person chips at the top</strong> to switch to the right family member if you don’t see that button.</span>
|
||||
<span class="d-block mt-2"><strong>New chores:</strong> Only a verified <strong>Head of household</strong> can add them (switch profile and enter PIN if needed).</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub (top of the page) to work with chores.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canReview && count($pendingForReview) > 0): ?>
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-header bg-warning-subtle">Waiting for your approval</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<?php foreach ($pendingForReview as $c): ?>
|
||||
<?php
|
||||
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$sub = $c['pending_submission'] ?? [];
|
||||
$byId = is_array($sub) ? (string) ($sub['submitted_by'] ?? '') : '';
|
||||
$byName = $nameById[$byId] ?? $byId;
|
||||
?>
|
||||
<li class="list-group-item d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<strong><?= sanitizeInput($c['title'] ?? '') ?></strong>
|
||||
<span class="text-muted small ms-1">— submitted by <?= sanitizeInput($byName) ?></span>
|
||||
<div class="small text-muted"><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?> · assignees:
|
||||
<?php
|
||||
$ids = $c['assignee_ids'] ?? [];
|
||||
$nm = [];
|
||||
foreach ($ids as $i) {
|
||||
$nm[] = $nameById[(string) $i] ?? (string) $i;
|
||||
}
|
||||
echo sanitizeInput(implode(', ', $nm));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success btn-sm btn-chore-approve" data-id="<?= $cid ?>">Approve</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-chore-reject" data-id="<?= $cid ?>">Reject</button>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($editNotFound): ?>
|
||||
<div class="alert alert-warning">That chore was not found. <a href="?tab=chores">Back to chores</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($showChoreForm): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><?= $editChore ? 'Edit chore' : 'New chore' ?></span>
|
||||
<?php if ($editChore): ?>
|
||||
<a href="?tab=chores" class="btn btn-sm btn-outline-secondary">Cancel edit</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="choreForm" class="row g-3">
|
||||
<input type="hidden" name="id" id="chore_id" value="<?= $editChore ? htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') : '' ?>">
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_title">Title</label>
|
||||
<input class="form-control" id="chore_title" required
|
||||
value="<?= $editChore ? sanitizeInput($editChore['title'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_value">Reward (<?= sanitizeInput($currencySymbol) ?>)</label>
|
||||
<input type="number" class="form-control" id="chore_value" min="0" step="0.01"
|
||||
value="<?= $editChore ? htmlspecialchars((string) ($editChore['value'] ?? '0'), ENT_QUOTES, 'UTF-8') : '0' ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="chore_description">Description</label>
|
||||
<textarea class="form-control" id="chore_description" rows="2"><?= $editChore ? sanitizeInput($editChore['description'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_image">Image URL (optional)</label>
|
||||
<input class="form-control" id="chore_image" type="url" placeholder="https://…"
|
||||
value="<?= $editChore ? sanitizeInput($editChore['image'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_due_date">Due date</label>
|
||||
<input class="form-control" id="chore_due_date" type="date"
|
||||
value="<?= $editChore ? sanitizeInput($editChore['due_date'] ?? '') : '' ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="chore_schedule">Schedule</label>
|
||||
<select class="form-select" id="chore_schedule">
|
||||
<option value="once" <?= !$editChore || ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_ONCE ? 'selected' : '' ?>>One-time</option>
|
||||
<option value="recurring" <?= $editChore && ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'selected' : '' ?>>Recurring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="chore_recurrence_wrap">
|
||||
<label class="form-label" for="chore_recurrence_days">Days until next due (recurring)</label>
|
||||
<input type="number" class="form-control" id="chore_recurrence_days" min="1" value="<?= $editChore ? (int) ($editChore['recurrence_days'] ?? 7) : 7 ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Assign to</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php
|
||||
$pid = (string) ($p['id'] ?? '');
|
||||
if ($pid === '') {
|
||||
continue;
|
||||
}
|
||||
$checked = $editChore && is_array($editChore['assignee_ids'] ?? null) && in_array($pid, $editChore['assignee_ids'], true);
|
||||
?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input chore-assignee" type="checkbox" value="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" id="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" <?= $checked ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_list_type">Checklist / list type</label>
|
||||
<select class="form-select" id="chore_list_type">
|
||||
<option value="checkbox" <?= $prefillList['type'] === 'checkbox' ? 'selected' : '' ?>>Checkbox list</option>
|
||||
<option value="unordered" <?= $prefillList['type'] === 'unordered' ? 'selected' : '' ?>>Bullet list</option>
|
||||
<option value="ordered" <?= $prefillList['type'] === 'ordered' ? 'selected' : '' ?>>Numbered list</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_list_items">List items (one per line)</label>
|
||||
<p class="form-text small mb-1">Shown on the card as a numbered, bullet, or checkbox-style list. Assignees tick these off in real life; finishing the chore is the <strong>Submit for approval</strong> button on the card.</p>
|
||||
<textarea class="form-control" id="chore_list_items" rows="4" placeholder="Take out recycling Wipe counters"><?= sanitizeInput(implode("\n", $prefillList['items'])) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-primary"><?= $editChore ? 'Save changes' : 'Add chore' ?></button>
|
||||
<?php if ($editChore): ?>
|
||||
<button type="button" class="btn btn-outline-danger" id="choreDeleteBtn" data-id="<?= htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Delete chore</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="choreFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($activePerson !== null && $editChore !== null && !$editChoreAllowed): ?>
|
||||
<div class="alert alert-warning mb-4">You can’t edit this chore. Only the person who created it or a verified Head of household can.</div>
|
||||
<?php elseif ($activePerson !== null && $editChore === null && !$canReview): ?>
|
||||
<p class="text-muted small mb-4">Switch to a verified <strong>Head of household</strong> (top of the page) to add new chores.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5 mt-4">Active chores</h3>
|
||||
<?php if (count($activeChores) === 0): ?>
|
||||
<p class="text-muted">No active chores.</p>
|
||||
<?php else: ?>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($activeChores as $c): ?>
|
||||
<?php
|
||||
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$assignees = $c['assignee_ids'] ?? [];
|
||||
$isAssignee = $actorId !== '' && is_array($assignees) && in_array((string) $actorId, $assignees, true);
|
||||
$canEdit = $activePerson && ((string) ($c['author_id'] ?? '') === (string) $actorId || $canReview);
|
||||
$pending = is_array($c['pending_submission'] ?? null);
|
||||
?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card chore-card h-100">
|
||||
<?php if (!empty($c['image']) && preg_match('#^https?://#i', (string) $c['image'])): ?>
|
||||
<img src="<?= sanitizeInput($c['image']) ?>" class="card-img-top chore-thumb" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="card-body">
|
||||
<h4 class="h6 card-title"><?= sanitizeInput($c['title'] ?? '') ?></h4>
|
||||
<p class="card-text small"><?= nl2br(sanitizeInput($c['description'] ?? '')) ?></p>
|
||||
<p class="small text-muted mb-1">
|
||||
Reward: <strong><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?></strong>
|
||||
<?php if (!empty($c['due_date'])): ?>
|
||||
· Due <?= sanitizeInput($c['due_date']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="small text-muted mb-2">
|
||||
<?= ($c['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'Recurring' : 'One-time' ?>
|
||||
<?php if ($pending): ?>
|
||||
<span class="badge text-bg-warning">Waiting for approval</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($c['lists']) && is_array($c['lists'])): ?>
|
||||
<p class="small text-muted mb-1"><strong>Steps</strong> (reference — use the button below when you’re done)</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach (($c['lists'] ?? []) as $block): ?>
|
||||
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
||||
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
||||
<?php if ($t === 'ordered'): ?>
|
||||
<ol class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
||||
<?php elseif ($t === 'unordered'): ?>
|
||||
<ul class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php else: ?>
|
||||
<ul class="list-unstyled small mb-2 chore-checklist-display"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1" aria-hidden="true"></i><span><?= sanitizeInput((string) $it) ?></span></li><?php endforeach; ?></ul>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<div class="d-flex flex-wrap gap-2 mt-auto align-items-center">
|
||||
<?php if ($isAssignee && !$pending): ?>
|
||||
<button type="button" class="btn btn-sm btn-success btn-chore-submit" data-id="<?= $cid ?>">Submit for approval</button>
|
||||
<?php elseif ($activePerson !== null && !$pending && !$isAssignee): ?>
|
||||
<?php
|
||||
$nmAssign = [];
|
||||
foreach ($assignees as $aid) {
|
||||
$nmAssign[] = $nameById[(string) $aid] ?? (string) $aid;
|
||||
}
|
||||
$who = sanitizeInput(implode(', ', $nmAssign));
|
||||
?>
|
||||
<span class="small text-muted">To submit when done: switch to <?= $who !== '' ? $who : 'an assignee' ?> at the top.</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($canEdit): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="?tab=chores&edit=<?= $cid ?>">Edit</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($completedChores) > 0): ?>
|
||||
<details class="mt-4">
|
||||
<summary class="h6">Completed chores (<?= count($completedChores) ?>)</summary>
|
||||
<ul class="list-group mt-2">
|
||||
<?php foreach ($completedChores as $c): ?>
|
||||
<li class="list-group-item text-muted"><?= sanitizeInput($c['title'] ?? '') ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
178
tabs/currency.php
Normal file
178
tabs/currency.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/expense_helpers.php';
|
||||
require_once __DIR__ . '/../includes/family_settings.php';
|
||||
|
||||
$sym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
|
||||
$name = trim((string) ($familySettings['currency_name'] ?? ''));
|
||||
$tabTitle = currencyTabLabel($familySettings);
|
||||
|
||||
$canRecordExpense = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$leaderboard = $people;
|
||||
usort($leaderboard, static function ($a, $b) {
|
||||
$ba = is_numeric($a['currency_balance'] ?? null) ? (float) $a['currency_balance'] : 0.0;
|
||||
$bb = is_numeric($b['currency_balance'] ?? null) ? (float) $b['currency_balance'] : 0.0;
|
||||
$cmp = $bb <=> $ba;
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
|
||||
$nameById = [];
|
||||
foreach ($people as $p) {
|
||||
if (!empty($p['id'])) {
|
||||
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
|
||||
usort($expenses, static function ($a, $b) {
|
||||
$da = (string) ($a['date'] ?? '');
|
||||
$db = (string) ($b['date'] ?? '');
|
||||
if ($db !== $da) {
|
||||
return strcmp($db, $da);
|
||||
}
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
$recentExpenses = array_slice($expenses, 0, 50);
|
||||
|
||||
$today = gmdate('Y-m-d');
|
||||
?>
|
||||
|
||||
<div id="currency" class="tab-content">
|
||||
<h2 class="mb-2"><?= htmlspecialchars($tabTitle, ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p class="text-muted small mb-4">Leaderboard and expenses use the family currency from Family settings.</p>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-trophy me-1" aria-hidden="true"></i> Leaderboard
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (count($leaderboard) === 0): ?>
|
||||
<p class="p-3 text-muted mb-0">Add people in Family settings to see balances.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0 leaderboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="text-muted">#</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col" class="text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($leaderboard as $rank => $p): ?>
|
||||
<?php
|
||||
$pid = (string) ($p['id'] ?? '');
|
||||
$bal = is_numeric($p['currency_balance'] ?? null) ? (float) $p['currency_balance'] : 0.0;
|
||||
$isSelf = $activePerson && ($activePerson['id'] ?? '') === $pid;
|
||||
?>
|
||||
<tr class="<?= $isSelf ? 'table-primary' : '' ?>">
|
||||
<td class="text-muted"><?= (int) $rank + 1 ?></td>
|
||||
<td><?= sanitizeInput($p['name'] ?? '') ?><?php if ($isSelf): ?> <span class="badge text-bg-info">You</span><?php endif; ?></td>
|
||||
<td class="text-end text-nowrap"><strong><?= htmlspecialchars(number_format($bal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<?php if ($canRecordExpense): ?>
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa fa-minus-circle me-1"></i> Record expense</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#expenseFormCollapse" aria-expanded="false" aria-controls="expenseFormCollapse">
|
||||
Show form
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="expenseFormCollapse">
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Deducts from one person’s balance (e.g. spent allowance). Requires enough balance.</p>
|
||||
<form id="expenseForm" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_title">Title</label>
|
||||
<input class="form-control" id="expense_title" required maxlength="120">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_value">Amount (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
|
||||
<input type="number" class="form-control" id="expense_value" min="0.01" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_date">Date</label>
|
||||
<input type="date" class="form-control" id="expense_date" value="<?= htmlspecialchars($today, ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_assignee">Charged to</label>
|
||||
<select class="form-select" id="expense_assignee" required>
|
||||
<option value="">Choose person…</option>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php if (empty($p['id'])) { continue; } ?>
|
||||
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="expense_description">Description</label>
|
||||
<textarea class="form-control" id="expense_description" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-danger">Apply expense</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="expenseFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (count($people) > 0): ?>
|
||||
<p class="text-muted small">Switch to a verified Head of household to record expenses.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Recent expenses</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (count($recentExpenses) === 0): ?>
|
||||
<p class="p-3 text-muted mb-0">No expenses recorded yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>To</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentExpenses as $ex): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= sanitizeInput((string) ($ex['date'] ?? '')) ?></td>
|
||||
<td><?= sanitizeInput((string) ($ex['title'] ?? '')) ?></td>
|
||||
<td><?= sanitizeInput($nameById[(string) ($ex['assignee_id'] ?? '')] ?? '') ?></td>
|
||||
<td class="text-end text-nowrap">−<?= htmlspecialchars(number_format((float) ($ex['value'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,25 +1,231 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$groceries = readJsonFile('groceries.json');
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
usort($stores, static function ($a, $b) {
|
||||
$sa = (int) ($a['sort'] ?? 0);
|
||||
$sb = (int) ($b['sort'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb;
|
||||
}
|
||||
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
|
||||
$selectedStore = isset($_GET['store']) ? trim((string) $_GET['store']) : '';
|
||||
if ($selectedStore === '' && count($stores) > 0) {
|
||||
$selectedStore = (string) ($stores[0]['id'] ?? '');
|
||||
}
|
||||
$currentStore = findStoreById($stores, $selectedStore);
|
||||
|
||||
$filter = isset($_GET['filter']) ? trim((string) $_GET['filter']) : 'active';
|
||||
if (!in_array($filter, ['active', 'purchased', 'pending', 'all'], true)) {
|
||||
$filter = 'active';
|
||||
}
|
||||
|
||||
$allItems = $currentStore ? ($lists['byStore'][$selectedStore] ?? []) : [];
|
||||
$displayItems = [];
|
||||
foreach ($allItems as $it) {
|
||||
if (!is_array($it)) {
|
||||
continue;
|
||||
}
|
||||
$st = (string) ($it['status'] ?? 'active');
|
||||
if ($filter === 'all') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'pending' && $st === 'pending_review') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'purchased' && $st === 'purchased') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'active' && $st === 'active') {
|
||||
$displayItems[] = $it;
|
||||
}
|
||||
}
|
||||
|
||||
$pickerOptions = $currentStore ? groceryCatalogPickerOptions($catalog, $selectedStore) : [];
|
||||
|
||||
$canReviewGroceries = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
?>
|
||||
|
||||
<div id="groceries" class="tab-content">
|
||||
<h2>Grocery List</h2>
|
||||
<div class="groceries-list">
|
||||
<?php if (empty($groceries)): ?>
|
||||
<p>No items in the grocery list yet.</p>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($groceries as $item): ?>
|
||||
<li>
|
||||
<span class="item-name"><?php echo sanitizeInput($item['name']); ?></span>
|
||||
<span class="item-quantity"><?php echo sanitizeInput($item['quantity']); ?></span>
|
||||
<span class="item-category"><?php echo sanitizeInput($item['category']); ?></span>
|
||||
</li>
|
||||
<div id="groceries" class="tab-content" data-store-id="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<h2 class="mb-3">Grocery list</h2>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub (top) to edit your list.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($stores) === 0): ?>
|
||||
<p class="text-muted">No stores yet. Add one under <strong>Family settings</strong>.</p>
|
||||
<?php else: ?>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<nav class="nav flex-lg-column nav-pills grocery-store-nav gap-1" aria-label="Stores">
|
||||
<?php foreach ($stores as $s): ?>
|
||||
<?php
|
||||
$sidRaw = (string) ($s['id'] ?? '');
|
||||
$sid = htmlspecialchars($sidRaw, ENT_QUOTES, 'UTF-8');
|
||||
$active = $sidRaw === $selectedStore;
|
||||
?>
|
||||
<a class="nav-link <?= $active ? 'active' : '' ?> py-2" href="?tab=groceries&store=<?= $sid ?>&filter=<?= htmlspecialchars($filter, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= sanitizeInput($s['name'] ?? 'Store') ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<p class="small text-muted mt-2 d-none d-lg-block">Manage store names under <a href="?tab=settings">Family settings</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9">
|
||||
<?php if (!$currentStore): ?>
|
||||
<p class="alert alert-warning">Pick a store from the list.</p>
|
||||
<?php else: ?>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||
<span class="text-muted small me-1">Show:</span>
|
||||
<?php
|
||||
$filtHref = static function ($f) use ($selectedStore) {
|
||||
return '?tab=groceries&store=' . rawurlencode($selectedStore) . '&filter=' . rawurlencode($f);
|
||||
};
|
||||
?>
|
||||
<a class="btn btn-sm <?= $filter === 'active' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('active'), ENT_QUOTES, 'UTF-8') ?>">To buy</a>
|
||||
<a class="btn btn-sm <?= $filter === 'purchased' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('purchased'), ENT_QUOTES, 'UTF-8') ?>">Purchased</a>
|
||||
<a class="btn btn-sm <?= $filter === 'pending' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('pending'), ENT_QUOTES, 'UTF-8') ?>">Pending review</a>
|
||||
<a class="btn btn-sm <?= $filter === 'all' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('all'), ENT_QUOTES, 'UTF-8') ?>">All</a>
|
||||
</div>
|
||||
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Add item — <?= sanitizeInput($currentStore['name'] ?? '') ?></div>
|
||||
<div class="card-body">
|
||||
<form id="groceryAddForm" class="row g-3">
|
||||
<input type="hidden" id="grocery_store_id" value="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="grocery_catalog_pick">Start from saved item (optional, deduped)</label>
|
||||
<select class="form-select" id="grocery_catalog_pick">
|
||||
<option value="">— New item —</option>
|
||||
<?php foreach ($pickerOptions as $opt): ?>
|
||||
<option
|
||||
value="<?= htmlspecialchars((string) ($opt['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-name="<?= htmlspecialchars((string) ($opt['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-description="<?= htmlspecialchars((string) ($opt['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-size="<?= htmlspecialchars((string) ($opt['defaultSize'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-image="<?= htmlspecialchars((string) ($opt['defaultImage'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
><?= sanitizeInput($opt['name'] ?? '') ?><?php if (!empty($opt['defaultSize'])): ?> (<?= sanitizeInput((string) $opt['defaultSize']) ?>)<?php endif; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_name">Name</label>
|
||||
<input class="form-control" id="grocery_name" required maxlength="200">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_quantity">Quantity</label>
|
||||
<input class="form-control" id="grocery_quantity" value="1">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_size">Size</label>
|
||||
<input class="form-control" id="grocery_size" placeholder="e.g. 1 gal">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_price">Price</label>
|
||||
<input class="form-control" id="grocery_price" placeholder="optional">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="grocery_description">Description</label>
|
||||
<textarea class="form-control" id="grocery_description" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="grocery_image">Image URL</label>
|
||||
<input class="form-control" id="grocery_image" type="url" placeholder="https://…">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="grocery_recurring">Recurring (days after purchase)</label>
|
||||
<input type="number" class="form-control" id="grocery_recurring" min="0" value="0" title="0 = not recurring; e.g. 7 = suggest again 7 days after you mark it purchased">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Add to list</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="groceryFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h6 text-muted"><?= $filter === 'purchased' ? 'Purchased' : ($filter === 'pending' ? 'Pending review' : ($filter === 'all' ? 'All items' : 'To buy')) ?></h3>
|
||||
<?php if (count($displayItems) === 0): ?>
|
||||
<p class="text-muted">No items in this view.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group grocery-item-list">
|
||||
<?php foreach ($displayItems as $it): ?>
|
||||
<?php
|
||||
$iid = htmlspecialchars((string) ($it['id'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$isPurchased = ($it['status'] ?? '') === 'purchased';
|
||||
$isPending = ($it['status'] ?? '') === 'pending_review';
|
||||
?>
|
||||
<li class="list-group-item grocery-line">
|
||||
<div class="d-flex flex-column flex-md-row gap-2 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<?php if (!empty($it['image']) && preg_match('#^https?://#i', (string) $it['image'])): ?>
|
||||
<img src="<?= sanitizeInput((string) $it['image']) ?>" alt="" class="grocery-line-thumb rounded">
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<strong><?= sanitizeInput((string) ($it['name'] ?? '')) ?></strong>
|
||||
<?php if (!empty($it['size'])): ?>
|
||||
<span class="text-muted small">· <?= sanitizeInput((string) $it['size']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($isPending): ?>
|
||||
<span class="badge text-bg-warning ms-1">Pending review</span>
|
||||
<?php endif; ?>
|
||||
<?php if ((int) ($it['recurringIntervalDays'] ?? 0) > 0): ?>
|
||||
<span class="badge text-bg-info ms-1">Recurring</span>
|
||||
<?php endif; ?>
|
||||
<div class="small text-muted">Qty <?= sanitizeInput((string) ($it['quantity'] ?? '')) ?>
|
||||
<?php if (!empty($it['price'])): ?> · <?= sanitizeInput((string) $it['price']) ?><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($it['description'])): ?>
|
||||
<div class="small"><?= nl2br(sanitizeInput((string) $it['description'])) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($isPurchased && !empty($it['purchasedAt'])): ?>
|
||||
<div class="small text-muted">Purchased <?= sanitizeInput((string) $it['purchasedAt']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group flex-shrink-0">
|
||||
<?php if ($activePerson !== null && !$isPending): ?>
|
||||
<?php if (!$isPurchased): ?>
|
||||
<button type="button" class="btn btn-sm btn-success btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="1">Got it</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="0">Mark not bought</button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($canReviewGroceries && $isPending): ?>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-grocery-approve" data-item-id="<?= $iid ?>">Approve</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-grocery-delete" data-item-id="<?= $iid ?>">Remove</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
378
tabs/meals.php
378
tabs/meals.php
@ -1,25 +1,373 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$meals = readJsonFile('meals.json');
|
||||
if (!file_exists(DATA_PATH . '/meal_plans.json')) {
|
||||
writeJsonFile('meal_plans.json', normalizeMealPlan([]));
|
||||
}
|
||||
|
||||
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
|
||||
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
|
||||
$detailId = isset($_GET['meal']) ? trim((string) $_GET['meal']) : '';
|
||||
$detailMeal = $detailId !== '' ? findMealById($meals, $detailId) : null;
|
||||
|
||||
$actorId = $activePerson['id'] ?? '';
|
||||
$canManageMeals = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$canEditMeal = static function (array $m) use ($actorId, $canManageMeals): bool {
|
||||
return $canManageMeals || (($m['author_id'] ?? '') === $actorId && $actorId !== '');
|
||||
};
|
||||
|
||||
$weekStart = $plan['weekStart'];
|
||||
$slots = $plan['slots'];
|
||||
|
||||
$mealTitleById = [];
|
||||
foreach ($meals as $m) {
|
||||
if (!empty($m['id'])) {
|
||||
$mealTitleById[(string) $m['id']] = (string) ($m['title'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$prefillMeal = null;
|
||||
$editMealId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
|
||||
if ($editMealId !== '') {
|
||||
$prefillMeal = findMealById($meals, $editMealId);
|
||||
}
|
||||
|
||||
$listsPrefill = $prefillMeal['lists'] ?? [['type' => 'checkbox', 'items' => []]];
|
||||
if (!is_array($listsPrefill) || count($listsPrefill) === 0) {
|
||||
$listsPrefill = [['type' => 'checkbox', 'items' => []]];
|
||||
}
|
||||
$firstList = $listsPrefill[0];
|
||||
$listType = in_array($firstList['type'] ?? '', ['ordered', 'unordered', 'checkbox'], true) ? $firstList['type'] : 'checkbox';
|
||||
$listItems = $firstList['items'] ?? [];
|
||||
if (!is_array($listItems)) {
|
||||
$listItems = [];
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($detailMeal !== null): ?>
|
||||
<div id="meals" class="tab-content">
|
||||
<h2>Meal Planning</h2>
|
||||
<div class="meals-list">
|
||||
<?php if (empty($meals)): ?>
|
||||
<p>No meals planned yet.</p>
|
||||
<p class="mb-2"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
||||
<h2 class="mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
|
||||
<?php if (!empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image'])): ?>
|
||||
<img src="<?= sanitizeInput((string) $detailMeal['image']) ?>" class="img-fluid rounded mb-3 meal-hero" alt="">
|
||||
<?php endif; ?>
|
||||
<p class="lead"><?= nl2br(sanitizeInput((string) ($detailMeal['description'] ?? ''))) ?></p>
|
||||
|
||||
<?php if (count($detailMeal['tags'] ?? []) > 0): ?>
|
||||
<p><?php foreach ($detailMeal['tags'] as $t): ?><span class="badge text-bg-secondary me-1"><?= sanitizeInput((string) $t) ?></span><?php endforeach; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5">Directions</h3>
|
||||
<div class="mb-4"><?= nl2br(sanitizeInput((string) ($detailMeal['directions'] ?? ''))) ?></div>
|
||||
|
||||
<?php foreach (($detailMeal['lists'] ?? []) as $block): ?>
|
||||
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
||||
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
||||
<?php if ($t === 'ordered'): ?>
|
||||
<ol><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
||||
<?php elseif ($t === 'unordered'): ?>
|
||||
<ul><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($meals as $meal): ?>
|
||||
<li>
|
||||
<span class="meal-name"><?php echo sanitizeInput($meal['name']); ?></span>
|
||||
<span class="meal-date"><?php echo formatDate($meal['date']); ?></span>
|
||||
<span class="meal-type"><?php echo sanitizeInput($meal['type']); ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<ul class="list-unstyled"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1"></i><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<h3 class="h5">Pantry / staples (ingredients)</h3>
|
||||
<?php if (count($detailMeal['ingredients'] ?? []) === 0): ?>
|
||||
<p class="text-muted small">None listed.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-3">
|
||||
<?php foreach ($detailMeal['ingredients'] as $ing): ?>
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<span><?= sanitizeInput((string) $ing) ?></span>
|
||||
<?php if ($activePerson !== null && count($stores) > 0): ?>
|
||||
<span class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<select class="form-select form-select-sm ingredient-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
|
||||
<?php foreach ($stores as $st): ?>
|
||||
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-ingredient-to-grocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>" data-ingredient="<?= htmlspecialchars((string) $ing, ENT_QUOTES, 'UTF-8') ?>">Add to grocery list</button>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5">Shopping items</h3>
|
||||
<?php if (count($detailMeal['items'] ?? []) === 0): ?>
|
||||
<p class="text-muted small">None listed.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-3">
|
||||
<?php foreach ($detailMeal['items'] as $it): ?>
|
||||
<li class="list-group-item"><?= sanitizeInput((string) ($it['name'] ?? '')) ?>
|
||||
<?php if (!empty($it['quantity'])): ?> · <?= sanitizeInput((string) $it['quantity']) ?><?php endif; ?>
|
||||
<?php if (!empty($it['size'])): ?> (<?= sanitizeInput((string) $it['size']) ?>)<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($activePerson !== null && count($detailMeal['items'] ?? []) > 0): ?>
|
||||
<button type="button" class="btn btn-primary mb-3" id="btnPushMealGrocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Send shopping items to grocery (pending review)</button>
|
||||
<div class="alert d-none" id="pushMealFeedback" role="status"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($detailMeal && $canEditMeal($detailMeal)): ?>
|
||||
<p><a class="btn btn-outline-primary" href="?tab=meals&edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<h2 class="mb-2">Meal plan</h2>
|
||||
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canManageMeals): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body row g-2 align-items-end">
|
||||
<div class="col-md-auto">
|
||||
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
|
||||
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered meal-grid-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Breakfast</th>
|
||||
<th>Lunch</th>
|
||||
<th>Dinner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php for ($d = 0; $d < 7; $d++): ?>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
|
||||
<?php foreach (mealSlotTypes() as $mt): ?>
|
||||
<?php
|
||||
$mid = $slots[(string) $d][$mt] ?? null;
|
||||
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
|
||||
?>
|
||||
<td>
|
||||
<?php if ($mid): ?>
|
||||
<a href="?tab=meals&meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
|
||||
data-day="<?= (int) $d ?>"
|
||||
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
><?= $mid ? 'Change' : '+' ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 class="h5">Meal library</h3>
|
||||
<?php if ($canManageMeals): ?>
|
||||
<p><a href="?tab=meals&edit=new" class="btn btn-success btn-sm">New meal</a></p>
|
||||
<?php else: ?>
|
||||
<p class="text-muted small">Only a verified Head of household can add meals.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($meals) === 0): ?>
|
||||
<p class="text-muted">No meals in the library yet.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-4">
|
||||
<?php foreach ($meals as $m): ?>
|
||||
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<a href="?tab=meals&meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
|
||||
<?php foreach (($m['tags'] ?? []) as $t): ?>
|
||||
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<span>
|
||||
<?php if ($canEditMeal($m)): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&edit=<?= $mid ?>">Edit</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canManageMeals && ($editMealId === 'new' || $prefillMeal !== null)): ?>
|
||||
<div class="card mb-4 border-primary" id="mealEditorCard">
|
||||
<div class="card-header"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></div>
|
||||
<div class="card-body">
|
||||
<?php if ($editMealId !== 'new' && $prefillMeal === null): ?>
|
||||
<div class="alert alert-warning">Meal not found.</div>
|
||||
<?php else: ?>
|
||||
<form id="mealSaveForm" class="row g-3">
|
||||
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="meal_title">Title</label>
|
||||
<input class="form-control" id="meal_title" required value="<?= $prefillMeal ? sanitizeInput($prefillMeal['title'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tags</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php
|
||||
$tagSet = $prefillMeal ? array_flip($prefillMeal['tags'] ?? []) : [];
|
||||
foreach (['breakfast', 'lunch', 'dinner'] as $tg):
|
||||
?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input meal-tag-cb" type="checkbox" value="<?= $tg ?>" id="tag_<?= $tg ?>" <?= isset($tagSet[$tg]) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="tag_<?= $tg ?>"><?= ucfirst($tg) ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_description">Description</label>
|
||||
<textarea class="form-control" id="meal_description" rows="2"><?= $prefillMeal ? sanitizeInput($prefillMeal['description'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="meal_image">Image URL</label>
|
||||
<input class="form-control" id="meal_image" type="url" value="<?= $prefillMeal ? sanitizeInput($prefillMeal['image'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_directions">Directions</label>
|
||||
<textarea class="form-control" id="meal_directions" rows="4"><?= $prefillMeal ? sanitizeInput($prefillMeal['directions'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="meal_list_type">Recipe list type</label>
|
||||
<select class="form-select" id="meal_list_type">
|
||||
<option value="checkbox" <?= $listType === 'checkbox' ? 'selected' : '' ?>>Checkbox</option>
|
||||
<option value="unordered" <?= $listType === 'unordered' ? 'selected' : '' ?>>Bullets</option>
|
||||
<option value="ordered" <?= $listType === 'ordered' ? 'selected' : '' ?>>Numbered</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="meal_list_items">Recipe list (one per line)</label>
|
||||
<textarea class="form-control" id="meal_list_items" rows="3"><?= sanitizeInput(implode("\n", $listItems)) ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_ingredients">Ingredients / staples (one per line)</label>
|
||||
<textarea class="form-control" id="meal_ingredients" rows="3" placeholder="Salt Olive oil"><?= $prefillMeal ? sanitizeInput(implode("\n", $prefillMeal['ingredients'] ?? [])) : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Shopping items</label>
|
||||
<p class="small text-muted">Per row: name, optional store, quantity, size, notes — store blank uses your first grocery store.</p>
|
||||
<div id="mealGroceryRows" class="d-flex flex-column gap-2">
|
||||
<?php
|
||||
$shopItems = $prefillMeal['items'] ?? [];
|
||||
if (!is_array($shopItems) || count($shopItems) === 0) {
|
||||
$shopItems = [['name' => '', 'storeId' => '', 'quantity' => '1', 'size' => '', 'description' => '', 'price' => '', 'image' => '']];
|
||||
}
|
||||
foreach ($shopItems as $si):
|
||||
?>
|
||||
<div class="row g-2 align-items-end meal-grocery-row">
|
||||
<div class="col-md-4">
|
||||
<input class="form-control form-control-sm g-name" placeholder="Name" value="<?= sanitizeInput((string) ($si['name'] ?? '')) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm g-store">
|
||||
<option value="">Default store</option>
|
||||
<?php foreach ($stores as $st): ?>
|
||||
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" <?= (($si['storeId'] ?? '') === ($st['id'] ?? '')) ? 'selected' : '' ?>><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input class="form-control form-control-sm g-qty" placeholder="Qty" value="<?= sanitizeInput((string) ($si['quantity'] ?? '1')) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input class="form-control form-control-sm g-size" placeholder="Size" value="<?= sanitizeInput((string) ($si['size'] ?? '')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAddGroceryRow">Add shopping row</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save meal</button>
|
||||
<a href="?tab=meals" class="btn btn-link">Cancel</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="mealSaveFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="mealSlotModalLabel">Choose a meal</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="slotPickerDay" value="">
|
||||
<input type="hidden" id="slotPickerType" value="">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Filter by tag (optional)</label>
|
||||
<select class="form-select" id="slotPickerFilter">
|
||||
<option value="">All meals</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="dinner">Dinner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="slotPickerPushGrocery" checked>
|
||||
<label class="form-check-label" for="slotPickerPushGrocery">Add this meal’s shopping items to grocery (pending review)</label>
|
||||
</div>
|
||||
<ul class="list-group" id="slotPickerList"></ul>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm mt-2" id="slotPickerClear">Clear slot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="mealsLibraryJson"><?= json_encode(array_map(static function ($m) {
|
||||
return [
|
||||
'id' => (string) ($m['id'] ?? ''),
|
||||
'title' => (string) ($m['title'] ?? ''),
|
||||
'tags' => array_values($m['tags'] ?? []),
|
||||
];
|
||||
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
225
tabs/settings.php
Normal file
225
tabs/settings.php
Normal file
@ -0,0 +1,225 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$canManage = count($people) === 0
|
||||
|| (
|
||||
$activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified()
|
||||
);
|
||||
|
||||
$permanenceOptions = [
|
||||
'permanent' => 'Permanent',
|
||||
'weekly' => 'Weekly',
|
||||
'biweekly' => 'Bi-weekly',
|
||||
'monthly' => 'Monthly',
|
||||
'quarterly' => 'Quarterly',
|
||||
'yearly' => 'Yearly',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="settings" class="tab-content">
|
||||
<h2 class="mb-3">Family settings</h2>
|
||||
<p class="text-muted">People and chore currency defaults apply across the whole hub. Heads of household verify with a PIN when switching profiles at the top of the page.</p>
|
||||
|
||||
<?php if (count($people) === 0): ?>
|
||||
<div class="card mb-4 border-primary" id="firstSetupCard">
|
||||
<div class="card-header bg-primary text-white">First-time setup</div>
|
||||
<div class="card-body">
|
||||
<p>Create the first <strong>Head of household</strong> profile so you can manage family members and settings.</p>
|
||||
<form id="firstHeadForm" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="first_name">Name</label>
|
||||
<input class="form-control" id="first_name" name="name" required autocomplete="name">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="first_pin">PIN (4+ characters)</label>
|
||||
<input type="password" class="form-control" id="first_pin" name="pin" required minlength="4" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="first_favoriteColor">Favorite color</label>
|
||||
<input type="color" class="form-control form-control-color" id="first_favoriteColor" name="favoriteColor" value="#4a90e2">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Create profile</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="firstHeadFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Chore economy</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$canManage && count($people) > 0): ?>
|
||||
<p class="text-muted">Switch to a verified Head of household to edit these settings.</p>
|
||||
<?php endif; ?>
|
||||
<form id="familySettingsForm" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_symbol">Currency symbol</label>
|
||||
<input class="form-control" id="currency_symbol" name="currency_symbol" maxlength="8"
|
||||
value="<?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_name">Currency name</label>
|
||||
<input class="form-control" id="currency_name" name="currency_name"
|
||||
value="<?= sanitizeInput($familySettings['currency_name'] ?? '') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_permanence">Balance reset</label>
|
||||
<select class="form-select" id="currency_permanence" name="currency_permanence" <?= !$canManage ? 'disabled' : '' ?>>
|
||||
<?php foreach ($permanenceOptions as $val => $label): ?>
|
||||
<option value="<?= htmlspecialchars($val, ENT_QUOTES, 'UTF-8') ?>" <?= ($familySettings['currency_permanence'] ?? '') === $val ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="timezone">Timezone</label>
|
||||
<input class="form-control" id="timezone" name="timezone"
|
||||
value="<?= sanitizeInput($familySettings['timezone'] ?? 'UTC') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="week_starts_on">Week starts on (0 = Sunday)</label>
|
||||
<input type="number" class="form-control" id="week_starts_on" name="week_starts_on" min="0" max="6"
|
||||
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<?php if ($canManage): ?>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="familySettingsFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$storesList = normalizeStoresList(readJsonFile('stores.json'));
|
||||
?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Grocery stores</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Each store has its own list in the <strong>Grocery list</strong> tab.</p>
|
||||
<?php if ($canManage): ?>
|
||||
<form id="storeAddForm" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="new_store_name">New store name</label>
|
||||
<input class="form-control" id="new_store_name" required maxlength="80" placeholder="e.g. Costco">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary">Add store</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="storeAddFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="text-muted small">Only a verified Head of household can manage stores.</p>
|
||||
<?php endif; ?>
|
||||
<?php if (count($storesList) === 0): ?>
|
||||
<p class="text-muted mb-0">No stores yet. Add one above (or open the Grocery list tab to get a default Home store).</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group">
|
||||
<?php foreach ($storesList as $st): ?>
|
||||
<?php $stid = htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<span><?= sanitizeInput($st['name'] ?? '') ?></span>
|
||||
<?php if ($canManage): ?>
|
||||
<span class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-store-rename" data-id="<?= $stid ?>" data-name="<?= htmlspecialchars((string) ($st['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Rename</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-store-delete" data-id="<?= $stid ?>">Delete</button>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($people) > 0): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>People</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($canManage): ?>
|
||||
<form id="addPersonForm" class="row g-3 mb-4 pb-3 border-bottom">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_name">Name</label>
|
||||
<input class="form-control" id="add_name" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_role">Role</label>
|
||||
<select class="form-select" id="add_role">
|
||||
<option value="<?= ROLE_CHILD ?>">Child</option>
|
||||
<option value="<?= ROLE_ADULT ?>">Adult</option>
|
||||
<option value="<?= ROLE_HEAD ?>">Head of household</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4" id="add_pin_wrap">
|
||||
<label class="form-label" for="add_pin">PIN (required for Head of household)</label>
|
||||
<input type="password" class="form-control" id="add_pin" minlength="4" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_favoriteColor">Favorite color</label>
|
||||
<input type="color" class="form-control form-control-color" id="add_favoriteColor" value="#4a90e2">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-success">Add person</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="addPersonFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Only a verified Head of household can add or edit people.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle" id="peopleTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th class="text-end">Balance</th>
|
||||
<?php if ($canManage): ?><th></th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<tr data-person-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
<td><?= sanitizeInput($p['name'] ?? '') ?></td>
|
||||
<td><?= sanitizeInput($p['role'] ?? '') ?></td>
|
||||
<td class="text-end"><?= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> <?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?></td>
|
||||
<?php if ($canManage): ?>
|
||||
<td class="text-end text-nowrap">
|
||||
<?php if (($p['role'] ?? '') === ROLE_HEAD): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-reset-pin" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Reset PIN</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-person" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Remove</button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
Loading…
x
Reference in New Issue
Block a user