First pass of features
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user