First pass of features

This commit is contained in:
2026-03-30 14:53:13 -05:00
parent 1a65e69d25
commit 6353f9c9b4
54 changed files with 5409 additions and 128 deletions
+42
View File
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/utils.php';
require_once __DIR__ . '/persona.php';
header('Content-Type: application/json; charset=utf-8');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
/**
* @return array<string, mixed>
*/
function readJsonBody(): array {
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function sendJson(array $payload, int $code = 200): void {
http_response_code($code);
echo json_encode($payload);
exit;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<string, mixed>
*/
function requireActivePerson(array $people): array {
$p = getActivePerson($people);
if ($p === null) {
sendJson(['success' => false, 'error' => 'Select who is using the hub first.'], 403);
}
return $p;
}
+198
View File
@@ -0,0 +1,198 @@
<?php
require_once __DIR__ . '/persona.php';
const CHORE_SCHEDULE_ONCE = 'once';
const CHORE_SCHEDULE_RECURRING = 'recurring';
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeChoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $people
* @return array<int, string>
*/
function legacyAssigneeNameToIds(string $name, array $people): array {
$name = trim($name);
if ($name === '') {
return [];
}
$lower = mb_strtolower($name, 'UTF-8');
$ids = [];
foreach ($people as $p) {
$pn = trim((string) ($p['name'] ?? ''));
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) {
$ids[] = (string) $p['id'];
}
}
return $ids;
}
/**
* @param array<string, mixed> $c
* @param array<int, array<string, mixed>> $people
* @return array<string, mixed>
*/
function migrateLegacyChoreRow(array $c, array $people): array {
if (isset($c['name']) && !isset($c['title'])) {
$c['title'] = (string) $c['name'];
}
if (!isset($c['assignee_ids']) || !is_array($c['assignee_ids'])) {
if (isset($c['assignee'])) {
$c['assignee_ids'] = legacyAssigneeNameToIds((string) $c['assignee'], $people);
} else {
$c['assignee_ids'] = [];
}
}
$ids = [];
foreach ($c['assignee_ids'] as $id) {
if (is_string($id) && $id !== '') {
$ids[] = $id;
}
}
$c['assignee_ids'] = array_values(array_unique($ids));
if (!isset($c['lists']) || !is_array($c['lists'])) {
$c['lists'] = [];
}
$c['lists'] = normalizeChoreLists($c['lists']);
if (!isset($c['description'])) {
$c['description'] = '';
}
if (!isset($c['image'])) {
$c['image'] = '';
}
if (!isset($c['author_id'])) {
$c['author_id'] = '';
}
if (!isset($c['value'])) {
$c['value'] = 0;
}
$c['value'] = is_numeric($c['value']) ? (float) $c['value'] : 0.0;
if (!isset($c['due_date'])) {
$c['due_date'] = '';
}
$c['due_date'] = is_string($c['due_date']) ? trim($c['due_date']) : '';
if ($c['due_date'] !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $c['due_date'])) {
$c['due_date'] = '';
}
$sched = $c['schedule'] ?? CHORE_SCHEDULE_ONCE;
$c['schedule'] = $sched === CHORE_SCHEDULE_RECURRING ? CHORE_SCHEDULE_RECURRING : CHORE_SCHEDULE_ONCE;
if (!isset($c['recurrence_days']) || !is_numeric($c['recurrence_days'])) {
$c['recurrence_days'] = 7;
}
$c['recurrence_days'] = max(1, (int) $c['recurrence_days']);
if (!isset($c['status'])) {
$c['status'] = 'active';
}
$st = (string) $c['status'];
$c['status'] = in_array($st, ['active', 'completed'], true) ? $st : 'active';
if (!array_key_exists('pending_submission', $c)) {
$c['pending_submission'] = null;
}
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
$c['pending_submission'] = null;
}
return $c;
}
/**
* @param mixed $lists
* @return array<int, array{type: string, items: array<int, string>}>
*/
function normalizeChoreLists($lists): array {
if (!is_array($lists)) {
return [];
}
$out = [];
foreach ($lists as $block) {
if (!is_array($block)) {
continue;
}
$type = isset($block['type']) ? (string) $block['type'] : 'checkbox';
if (!in_array($type, ['ordered', 'unordered', 'checkbox'], true)) {
$type = 'checkbox';
}
$items = $block['items'] ?? [];
if (!is_array($items)) {
$items = [];
}
$clean = [];
foreach ($items as $it) {
$s = trim((string) $it);
if ($s !== '') {
$clean[] = $s;
}
}
$out[] = ['type' => $type, 'items' => $clean];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
* @return array<int, array<string, mixed>>
*/
function migrateAllChores(array $chores, array $people): array {
$out = [];
foreach ($chores as $c) {
$out[] = migrateLegacyChoreRow($c, $people);
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreById(array $chores, string $id): ?array {
foreach ($chores as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $chores
*/
function findChoreIndexById(array $chores, string $id): ?int {
foreach ($chores as $i => $c) {
if (($c['id'] ?? '') === $id) {
return $i;
}
}
return null;
}
/**
* @param array<int, string> $assigneeIds
* @param array<int, array<string, mixed>> $people
*/
function choreAssigneeIdsValid(array $assigneeIds, array $people): bool {
$set = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$set[(string) $p['id']] = true;
}
}
foreach ($assigneeIds as $id) {
if (!isset($set[$id])) {
return false;
}
}
return true;
}
+59 -17
View File
@@ -1,23 +1,65 @@
<?php
function readJsonFile($filename) {
$filepath = __DIR__ . '/../data/' . $filename;
function getDataDirectory(): string {
return __DIR__ . '/../data';
}
function getDataFilePath(string $filename): string {
return getDataDirectory() . '/' . $filename;
}
function ensureDataDirectory(): void {
$dataDir = getDataDirectory();
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
}
function readJsonFile(string $filename) {
$filepath = getDataFilePath($filename);
if (!file_exists($filepath)) {
return [];
}
$content = file_get_contents($filepath);
return json_decode($content, true) ?? [];
}
function writeJsonFile($filename, $data) {
$filepath = __DIR__ . '/../data/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT);
return file_put_contents($filepath, $json);
}
function ensureDataDirectory() {
$dataDir = __DIR__ . '/../data';
if (!file_exists($dataDir)) {
mkdir($dataDir, 0755, true);
$fp = fopen($filepath, 'rb');
if ($fp === false) {
return [];
}
}
if (!flock($fp, LOCK_SH)) {
fclose($fp);
return [];
}
$content = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
if ($content === false || $content === '') {
return [];
}
$decoded = json_decode($content, true);
return $decoded ?? [];
}
function writeJsonFile(string $filename, $data): bool {
ensureDataDirectory();
$filepath = getDataFilePath($filename);
$fp = fopen($filepath, 'c+');
if ($fp === false) {
return false;
}
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return false;
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) {
flock($fp, LOCK_UN);
fclose($fp);
return false;
}
ftruncate($fp, 0);
rewind($fp);
$written = fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $written !== false;
}
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeExpensesList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/db.php';
function defaultFamilySettings(): array {
return [
'currency_symbol' => '★',
'currency_name' => 'Stars',
'currency_permanence' => 'permanent',
'timezone' => 'UTC',
'week_starts_on' => 0,
];
}
function loadFamilySettings(): array {
$raw = readJsonFile('family_settings.json');
if (!is_array($raw)) {
return defaultFamilySettings();
}
return array_merge(defaultFamilySettings(), $raw);
}
/**
* Tab label: symbol + name (e.g. "★ Stars").
*/
function currencyTabLabel(array $familySettings): string {
$sym = trim((string) ($familySettings['currency_symbol'] ?? ''));
$name = trim((string) ($familySettings['currency_name'] ?? ''));
$label = trim($sym . ' ' . $name);
return $label !== '' ? $label : 'Currency';
}
+395
View File
@@ -0,0 +1,395 @@
<?php
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeStoresList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeCatalogList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
$n = mb_strtolower(trim($name), 'UTF-8');
$s = mb_strtolower(trim($size), 'UTF-8');
return $storeId . '|' . $n . '|' . $s;
}
/**
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function defaultGroceryListsShape(): array {
return ['byStore' => []];
}
/**
* @param mixed $raw
* @return array{byStore: array<string, array<int, array<string, mixed>>>}
*/
function normalizeGroceryLists($raw): array {
$base = defaultGroceryListsShape();
if (!is_array($raw)) {
return $base;
}
if (isset($raw['byStore']) && is_array($raw['byStore'])) {
$base['byStore'] = [];
foreach ($raw['byStore'] as $storeId => $items) {
$sid = (string) $storeId;
if ($sid === '') {
continue;
}
if (!is_array($items) || !array_is_list($items)) {
$base['byStore'][$sid] = [];
continue;
}
$clean = [];
foreach ($items as $it) {
if (is_array($it) && !empty($it['id']) && is_string($it['id'])) {
$clean[] = $it;
}
}
$base['byStore'][$sid] = $clean;
}
return $base;
}
return $base;
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function findStoreById(array $stores, string $id): ?array {
foreach ($stores as $s) {
if (($s['id'] ?? '') === $id) {
return $s;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $catalog
*/
function findCatalogById(array $catalog, string $id): ?array {
foreach ($catalog as $c) {
if (($c['id'] ?? '') === $id) {
return $c;
}
}
return null;
}
/**
* Deduped rows for picker: one per dedupeKey (first wins for label).
*
* @param array<int, array<string, mixed>> $catalog
* @return array<int, array<string, mixed>>
*/
function groceryCatalogPickerOptions(array $catalog, ?string $storeId = null): array {
$seen = [];
$out = [];
foreach ($catalog as $row) {
if ($storeId !== null && (string) ($row['storeId'] ?? '') !== $storeId) {
continue;
}
$key = (string) ($row['dedupeKey'] ?? '');
if ($key === '') {
$key = groceryCatalogDedupeKey(
(string) ($row['storeId'] ?? ''),
(string) ($row['name'] ?? ''),
(string) ($row['defaultSize'] ?? '')
);
}
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort($out, static function ($a, $b) {
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return $out;
}
/**
* Migrate legacy groceries.json flat list into stores + lists + empty catalog.
*
* @return array{stores: array<int, array<string, mixed>>, lists: array{byStore: array<string, array<int, array<string, mixed>>>}, migrated: bool}
*/
function migrateLegacyGroceriesIfNeeded(): array {
$listsPath = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
$hasNew = count($listsPath['byStore']) > 0;
$legacy = readJsonFile('groceries.json');
$legacyList = is_array($legacy) && array_is_list($legacy) ? $legacy : [];
if ($hasNew || count($legacyList) === 0) {
return ['stores' => normalizeStoresList(readJsonFile('stores.json')), 'lists' => $listsPath, 'migrated' => false];
}
$storeId = bin2hex(random_bytes(8));
$stores = [[
'id' => $storeId,
'name' => 'General',
'sort' => 0,
]];
$items = [];
foreach ($legacyList as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$cat = trim((string) ($row['category'] ?? ''));
$desc = trim((string) ($row['description'] ?? ''));
if ($cat !== '' && $desc !== '') {
$desc = 'Category: ' . $cat . "\n" . $desc;
} elseif ($cat !== '') {
$desc = 'Category: ' . $cat;
}
$items[] = [
'id' => bin2hex(random_bytes(8)),
'catalogId' => null,
'name' => $name,
'description' => $desc,
'size' => '',
'quantity' => (string) ($row['quantity'] ?? '1'),
'price' => '',
'image' => '',
'status' => 'active',
'purchasedAt' => null,
'source' => 'manual',
'recurringIntervalDays' => 0,
'addedAt' => gmdate('c'),
];
}
$listsPath['byStore'][$storeId] = $items;
writeJsonFile('stores.json', $stores);
writeJsonFile('grocery_lists.json', $listsPath);
return ['stores' => $stores, 'lists' => $listsPath, 'migrated' => true];
}
/**
* Create catalog row + list line (used by grocery_item_create and meal → grocery).
*
* @param array<int, array<string, mixed>> $stores
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
*/
function groceryAppendShoppingLine(
array $stores,
string $storeId,
string $name,
string $description = '',
string $size = '',
string $quantity = '1',
string $price = '',
string $image = '',
string $source = 'manual',
int $recurringIntervalDays = 0,
?string $mealId = null,
?string $mealTitleMeta = null
): array {
if (findStoreById($stores, $storeId) === null) {
return ['ok' => false, 'error' => 'Invalid store'];
}
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'error' => 'Name is required'];
}
$description = trim($description);
$size = trim($size);
$quantity = trim($quantity) !== '' ? trim($quantity) : '1';
$price = trim($price);
$image = trim($image);
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
$catalogId = null;
foreach ($catalog as $i => $c) {
if ((string) ($c['storeId'] ?? '') === $storeId && (string) ($c['dedupeKey'] ?? '') === $dedupeKey) {
$catalogId = (string) $c['id'];
$catalog[$i]['name'] = $name;
$catalog[$i]['description'] = $description;
$catalog[$i]['defaultSize'] = $size;
$catalog[$i]['defaultImage'] = $image;
$catalog[$i]['dedupeKey'] = $dedupeKey;
break;
}
}
if ($catalogId === null) {
$catalogId = bin2hex(random_bytes(8));
$catalog[] = [
'id' => $catalogId,
'storeId' => $storeId,
'dedupeKey' => $dedupeKey,
'name' => $name,
'description' => $description,
'defaultSize' => $size,
'defaultImage' => $image,
'lastPurchaseAt' => null,
'recurringIntervalDays' => 0,
'nextDueDate' => null,
];
}
$status = 'active';
if ($source === 'meal_plan' || $source === 'pending_review') {
$status = 'pending_review';
}
$src = $source === 'pending_review' ? 'meal_plan' : $source;
if (!in_array($src, ['manual', 'meal_plan'], true)) {
$src = 'manual';
}
$line = normalizeGroceryLineItem([
'id' => bin2hex(random_bytes(8)),
'catalogId' => $catalogId,
'name' => $name,
'description' => $description,
'size' => $size,
'quantity' => $quantity,
'price' => $price,
'image' => $image,
'status' => $status,
'purchasedAt' => null,
'source' => $src,
'recurringIntervalDays' => max(0, $recurringIntervalDays),
'addedAt' => gmdate('c'),
]);
if ($mealId !== null && $mealId !== '') {
$line['mealId'] = $mealId;
}
if ($mealTitleMeta !== null && $mealTitleMeta !== '') {
$line['mealTitle'] = $mealTitleMeta;
}
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$storeId])) {
$lists['byStore'][$storeId] = [];
}
$lists['byStore'][$storeId][] = $line;
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
return ['ok' => false, 'error' => 'Failed to save catalog'];
}
if (!writeJsonFile('grocery_lists.json', $lists)) {
return ['ok' => false, 'error' => 'Failed to save grocery list'];
}
return ['ok' => true, 'item' => $line];
}
/**
* @param array<int, array<string, mixed>> $stores
*/
function groceryFirstStoreId(array $stores): string {
if (count($stores) === 0) {
return '';
}
$copy = $stores;
usort($copy, static function ($a, $b) {
$sa = (int) ($a['sort'] ?? 0);
$sb = (int) ($b['sort'] ?? 0);
if ($sa !== $sb) {
return $sa <=> $sb;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
return (string) ($copy[0]['id'] ?? '');
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
function normalizeGroceryLineItem(array $item): array {
if (empty($item['id']) || !is_string($item['id'])) {
$item['id'] = bin2hex(random_bytes(8));
}
$item['name'] = trim((string) ($item['name'] ?? ''));
$item['description'] = trim((string) ($item['description'] ?? ''));
$item['size'] = trim((string) ($item['size'] ?? ''));
$item['quantity'] = trim((string) ($item['quantity'] ?? '1'));
$item['price'] = trim((string) ($item['price'] ?? ''));
$item['image'] = trim((string) ($item['image'] ?? ''));
$st = (string) ($item['status'] ?? 'active');
$item['status'] = in_array($st, ['active', 'purchased', 'pending_review'], true) ? $st : 'active';
$item['purchasedAt'] = isset($item['purchasedAt']) && is_string($item['purchasedAt']) ? $item['purchasedAt'] : null;
$item['source'] = in_array($item['source'] ?? 'manual', ['manual', 'meal_plan', 'meal_detail'], true)
? $item['source']
: 'manual';
$item['recurringIntervalDays'] = max(0, (int) ($item['recurringIntervalDays'] ?? 0));
$item['catalogId'] = isset($item['catalogId']) && is_string($item['catalogId']) ? $item['catalogId'] : null;
if (empty($item['addedAt'])) {
$item['addedAt'] = gmdate('c');
}
if (!empty($item['mealId']) && is_string($item['mealId'])) {
$item['mealId'] = trim($item['mealId']);
} else {
unset($item['mealId']);
}
if (isset($item['mealTitle']) && is_string($item['mealTitle'])) {
$item['mealTitle'] = trim($item['mealTitle']);
} else {
unset($item['mealTitle']);
}
return $item;
}
/**
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
*/
function groceryStoreHasItems(array $lists, string $storeId): bool {
$items = $lists['byStore'][$storeId] ?? [];
return is_array($items) && count($items) > 0;
}
/**
* If there are no stores yet, create a single "Home" store and empty list bucket.
*/
function ensureDefaultGroceryStore(): void {
$stores = normalizeStoresList(readJsonFile('stores.json'));
if (count($stores) > 0) {
return;
}
$id = bin2hex(random_bytes(8));
writeJsonFile('stores.json', [['id' => $id, 'name' => 'Home', 'sort' => 0]]);
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
if (!isset($lists['byStore'][$id])) {
$lists['byStore'][$id] = [];
}
writeJsonFile('grocery_lists.json', $lists);
}
+83 -10
View File
@@ -4,20 +4,93 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Hub</title>
<!-- Bootstrap CSS from CDN -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/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
View File
@@ -0,0 +1,298 @@
<?php
require_once __DIR__ . '/chore_helpers.php';
require_once __DIR__ . '/grocery_helpers.php';
const MEAL_SLOT_BREAKFAST = 'breakfast';
const MEAL_SLOT_LUNCH = 'lunch';
const MEAL_SLOT_DINNER = 'dinner';
/** @return array<int, string> */
function mealSlotTypes(): array {
return [MEAL_SLOT_BREAKFAST, MEAL_SLOT_LUNCH, MEAL_SLOT_DINNER];
}
function mealCurrentWeekStart(): string {
try {
$d = new DateTimeImmutable('monday this week');
return $d->format('Y-m-d');
} catch (Exception $e) {
return gmdate('Y-m-d');
}
}
/**
* @return array<string, array<string, string|null>>
*/
function mealDefaultEmptySlots(): array {
$slots = [];
for ($i = 0; $i < 7; $i++) {
$key = (string) $i;
$slots[$key] = [
MEAL_SLOT_BREAKFAST => null,
MEAL_SLOT_LUNCH => null,
MEAL_SLOT_DINNER => null,
];
}
return $slots;
}
/**
* @param mixed $raw
* @return array{weekStart: string, slots: array<string, array<string, string|null>>}
*/
function normalizeMealPlan($raw): array {
$base = [
'weekStart' => mealCurrentWeekStart(),
'slots' => mealDefaultEmptySlots(),
];
if (!is_array($raw)) {
return $base;
}
$ws = isset($raw['weekStart']) ? trim((string) $raw['weekStart']) : '';
if ($ws !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $ws)) {
$base['weekStart'] = $ws;
}
$slotsIn = $raw['slots'] ?? [];
if (is_array($slotsIn)) {
for ($i = 0; $i < 7; $i++) {
$key = (string) $i;
$day = $slotsIn[$key] ?? $slotsIn[$i] ?? [];
if (!is_array($day)) {
continue;
}
foreach (mealSlotTypes() as $mt) {
$mid = $day[$mt] ?? null;
if ($mid === null || $mid === '') {
$base['slots'][$key][$mt] = null;
} else {
$base['slots'][$key][$mt] = is_string($mid) ? $mid : null;
}
}
}
}
return $base;
}
function loadMealPlan(): array {
return normalizeMealPlan(readJsonFile('meal_plans.json'));
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizeMealsList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
/**
* @param array<string, mixed> $m
* @return array<string, mixed>
*/
function normalizeMealRow(array $m): array {
if (isset($m['name']) && !isset($m['title'])) {
$m['title'] = trim((string) $m['name']);
}
$m['title'] = trim((string) ($m['title'] ?? ''));
$m['image'] = trim((string) ($m['image'] ?? ''));
$m['description'] = trim((string) ($m['description'] ?? ''));
$m['directions'] = trim((string) ($m['directions'] ?? ''));
if (!isset($m['lists']) || !is_array($m['lists'])) {
$m['lists'] = [];
}
$m['lists'] = normalizeChoreLists($m['lists']);
$tags = $m['tags'] ?? [];
if (!is_array($tags)) {
$tags = [];
}
$cleanTags = [];
foreach ($tags as $t) {
$t = strtolower(trim((string) $t));
if (in_array($t, ['breakfast', 'lunch', 'dinner'], true)) {
$cleanTags[] = $t;
}
}
if (isset($m['type']) && is_string($m['type']) && count($cleanTags) === 0) {
$tt = strtolower(trim($m['type']));
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
$cleanTags[] = $tt;
}
}
$m['tags'] = array_values(array_unique($cleanTags));
$ing = $m['ingredients'] ?? [];
if (!is_array($ing)) {
$ing = [];
}
$ingOut = [];
foreach ($ing as $line) {
$s = trim((string) $line);
if ($s !== '') {
$ingOut[] = $s;
}
}
$m['ingredients'] = $ingOut;
$items = $m['items'] ?? [];
if (!is_array($items)) {
$items = [];
}
$itemOut = [];
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$nm = trim((string) ($it['name'] ?? ''));
if ($nm === '') {
continue;
}
$itemOut[] = [
'name' => $nm,
'storeId' => trim((string) ($it['storeId'] ?? '')),
'description' => trim((string) ($it['description'] ?? '')),
'size' => trim((string) ($it['size'] ?? '')),
'quantity' => trim((string) ($it['quantity'] ?? '1')) ?: '1',
'price' => trim((string) ($it['price'] ?? '')),
'image' => trim((string) ($it['image'] ?? '')),
];
}
$m['items'] = $itemOut;
if (!isset($m['author_id'])) {
$m['author_id'] = '';
}
return $m;
}
/**
* @param array<int, array<string, mixed>> $meals
*/
function findMealById(array $meals, string $id): ?array {
foreach ($meals as $m) {
if (($m['id'] ?? '') === $id) {
return $m;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $meals
*/
function findMealIndexById(array $meals, string $id): ?int {
foreach ($meals as $i => $m) {
if (($m['id'] ?? '') === $id) {
return $i;
}
}
return null;
}
/**
* Migrate legacy meals (name, date, type) into library format.
*
* @param array<int, array<string, mixed>> $meals
* @return array<int, array<string, mixed>>
*/
function migrateLegacyMealsList(array $meals): array {
$out = [];
foreach ($meals as $m) {
if (!is_array($m)) {
continue;
}
if (isset($m['title']) && isset($m['tags'])) {
$out[] = normalizeMealRow($m);
continue;
}
$title = trim((string) ($m['title'] ?? $m['name'] ?? ''));
if ($title === '') {
continue;
}
$tags = [];
if (!empty($m['type'])) {
$tt = strtolower(trim((string) $m['type']));
if (in_array($tt, ['breakfast', 'lunch', 'dinner'], true)) {
$tags[] = $tt;
}
}
$out[] = normalizeMealRow([
'id' => !empty($m['id']) && is_string($m['id']) ? $m['id'] : bin2hex(random_bytes(8)),
'title' => $title,
'tags' => $tags,
'description' => isset($m['date']) ? 'Legacy date: ' . (string) $m['date'] : '',
'image' => '',
'directions' => '',
'lists' => [],
'ingredients' => [],
'items' => [],
'author_id' => '',
]);
}
return $out;
}
/**
* Push meal shopping items to grocery lists as pending_review.
*
* @param array<int, array<string, mixed>> $stores
* @return int number of lines added
*/
function pushMealItemsToGrocery(array $meal, array $stores): int {
$items = $meal['items'] ?? [];
if (!is_array($items) || count($items) === 0) {
return 0;
}
$mealId = (string) ($meal['id'] ?? '');
$mealTitle = (string) ($meal['title'] ?? '');
$defaultStore = groceryFirstStoreId($stores);
if ($defaultStore === '') {
return 0;
}
$added = 0;
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
$sid = trim((string) ($row['storeId'] ?? ''));
if ($sid === '' || findStoreById($stores, $sid) === null) {
$sid = $defaultStore;
}
$res = groceryAppendShoppingLine(
$stores,
$sid,
$name,
trim((string) ($row['description'] ?? '')),
trim((string) ($row['size'] ?? '')),
trim((string) ($row['quantity'] ?? '1')) ?: '1',
trim((string) ($row['price'] ?? '')),
trim((string) ($row['image'] ?? '')),
'meal_plan',
0,
$mealId !== '' ? $mealId : null,
$mealTitle !== '' ? $mealTitle : null
);
if ($res['ok']) {
$added++;
}
}
return $added;
}
function mealDayShortLabel(string $weekStart, int $offset): string {
$ts = strtotime($weekStart . ' UTC +' . $offset . ' days');
if ($ts === false) {
return (string) $offset;
}
return gmdate('D n/j', $ts);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/db.php';
const ROLE_HEAD = 'head_of_household';
const ROLE_ADULT = 'adult';
const ROLE_CHILD = 'child';
const SESSION_ACTIVE_PERSON = 'active_person_id';
const SESSION_HOH_VERIFIED = 'hoh_verified';
function startFamilyHubSession(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function getActivePersonId(): ?string {
$id = $_SESSION[SESSION_ACTIVE_PERSON] ?? null;
return is_string($id) && $id !== '' ? $id : null;
}
function setSessionPerson(string $personId, bool $hohVerified): void {
$_SESSION[SESSION_ACTIVE_PERSON] = $personId;
$_SESSION[SESSION_HOH_VERIFIED] = $hohVerified;
}
function clearPersonaSession(): void {
unset($_SESSION[SESSION_ACTIVE_PERSON], $_SESSION[SESSION_HOH_VERIFIED]);
}
function isHohVerified(): bool {
return !empty($_SESSION[SESSION_HOH_VERIFIED]);
}
function findPersonById(array $people, string $id): ?array {
foreach ($people as $p) {
if (($p['id'] ?? '') === $id) {
return $p;
}
}
return null;
}
function getActivePerson(array $people): ?array {
$id = getActivePersonId();
if ($id === null) {
return null;
}
return findPersonById($people, $id);
}
function personRequiresPinToActivate(?array $person): bool {
if ($person === null) {
return false;
}
return ($person['role'] ?? '') === ROLE_HEAD && !empty($person['pin_hash']);
}
function assertHoHCanManagePeople(array $people): void {
if (count($people) === 0) {
return;
}
$active = getActivePerson($people);
if ($active === null || ($active['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Head of household verification required.']);
exit;
}
}
/**
* @param mixed $raw
* @return array<int, array<string, mixed>>
*/
function normalizePeopleList($raw): array {
if (!is_array($raw) || !array_is_list($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (is_array($row) && !empty($row['id']) && is_string($row['id'])) {
$out[] = $row;
}
}
return $out;
}
+13 -1
View File
@@ -20,4 +20,16 @@ function ensureExportDirectory() {
if (!file_exists(EXPORT_DESTINATION)) {
mkdir(EXPORT_DESTINATION, 0755, true);
}
}
}
/**
* Base path prefix for JSON API under this app (e.g. /familyHub/api).
*/
function familyHubWebApiBase(): string {
$sd = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
$sd = rtrim(str_replace('\\', '/', $sd), '/');
if ($sd === '' || $sd === '/') {
return '/api';
}
return $sd . '/api';
}