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
+363 -15
View File
@@ -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">&larr; 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&amp;edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a></p>
<?php endif; ?>
</div>
<?php else: ?>
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
<h2 class="mb-2">Meal plan</h2>
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
<?php if ($activePerson === null): ?>
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
<?php endif; ?>
<?php if ($canManageMeals): ?>
<div class="card mb-4">
<div class="card-body row g-2 align-items-end">
<div class="col-md-auto">
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="col-md-auto">
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
</div>
<div class="col-12">
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="table-responsive mb-4">
<table class="table table-bordered meal-grid-table">
<thead>
<tr>
<th></th>
<th>Breakfast</th>
<th>Lunch</th>
<th>Dinner</th>
</tr>
</thead>
<tbody>
<?php for ($d = 0; $d < 7; $d++): ?>
<tr>
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
<?php foreach (mealSlotTypes() as $mt): ?>
<?php
$mid = $slots[(string) $d][$mt] ?? null;
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
?>
<td>
<?php if ($mid): ?>
<a href="?tab=meals&amp;meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
<?php if ($activePerson !== null): ?>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
data-day="<?= (int) $d ?>"
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
><?= $mid ? 'Change' : '+' ?></button>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
<h3 class="h5">Meal library</h3>
<?php if ($canManageMeals): ?>
<p><a href="?tab=meals&amp;edit=new" class="btn btn-success btn-sm">New meal</a></p>
<?php else: ?>
<p class="text-muted small">Only a verified Head of household can add meals.</p>
<?php endif; ?>
<?php if (count($meals) === 0): ?>
<p class="text-muted">No meals in the library yet.</p>
<?php else: ?>
<ul class="list-group mb-4">
<?php foreach ($meals as $m): ?>
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<a href="?tab=meals&amp;meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
<?php foreach (($m['tags'] ?? []) as $t): ?>
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
<?php endforeach; ?>
</div>
<span>
<?php if ($canEditMeal($m)): ?>
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&amp;edit=<?= $mid ?>">Edit</a>
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
<?php endif; ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($canManageMeals && ($editMealId === 'new' || $prefillMeal !== null)): ?>
<div class="card mb-4 border-primary" id="mealEditorCard">
<div class="card-header"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></div>
<div class="card-body">
<?php if ($editMealId !== 'new' && $prefillMeal === null): ?>
<div class="alert alert-warning">Meal not found.</div>
<?php else: ?>
<form id="mealSaveForm" class="row g-3">
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
<div class="col-md-8">
<label class="form-label" for="meal_title">Title</label>
<input class="form-control" id="meal_title" required value="<?= $prefillMeal ? sanitizeInput($prefillMeal['title'] ?? '') : '' ?>">
</div>
<div class="col-md-4">
<label class="form-label">Tags</label>
<div class="d-flex flex-wrap gap-2">
<?php
$tagSet = $prefillMeal ? array_flip($prefillMeal['tags'] ?? []) : [];
foreach (['breakfast', 'lunch', 'dinner'] as $tg):
?>
<div class="form-check">
<input class="form-check-input meal-tag-cb" type="checkbox" value="<?= $tg ?>" id="tag_<?= $tg ?>" <?= isset($tagSet[$tg]) ? 'checked' : '' ?>>
<label class="form-check-label" for="tag_<?= $tg ?>"><?= ucfirst($tg) ?></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="col-12">
<label class="form-label" for="meal_description">Description</label>
<textarea class="form-control" id="meal_description" rows="2"><?= $prefillMeal ? sanitizeInput($prefillMeal['description'] ?? '') : '' ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label" for="meal_image">Image URL</label>
<input class="form-control" id="meal_image" type="url" value="<?= $prefillMeal ? sanitizeInput($prefillMeal['image'] ?? '') : '' ?>">
</div>
<div class="col-12">
<label class="form-label" for="meal_directions">Directions</label>
<textarea class="form-control" id="meal_directions" rows="4"><?= $prefillMeal ? sanitizeInput($prefillMeal['directions'] ?? '') : '' ?></textarea>
</div>
<div class="col-md-4">
<label class="form-label" for="meal_list_type">Recipe list type</label>
<select class="form-select" id="meal_list_type">
<option value="checkbox" <?= $listType === 'checkbox' ? 'selected' : '' ?>>Checkbox</option>
<option value="unordered" <?= $listType === 'unordered' ? 'selected' : '' ?>>Bullets</option>
<option value="ordered" <?= $listType === 'ordered' ? 'selected' : '' ?>>Numbered</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label" for="meal_list_items">Recipe list (one per line)</label>
<textarea class="form-control" id="meal_list_items" rows="3"><?= sanitizeInput(implode("\n", $listItems)) ?></textarea>
</div>
<div class="col-12">
<label class="form-label" for="meal_ingredients">Ingredients / staples (one per line)</label>
<textarea class="form-control" id="meal_ingredients" rows="3" placeholder="Salt&#10;Olive oil"><?= $prefillMeal ? sanitizeInput(implode("\n", $prefillMeal['ingredients'] ?? [])) : '' ?></textarea>
</div>
<div class="col-12">
<label class="form-label">Shopping items</label>
<p class="small text-muted">Per row: name, optional store, quantity, size, notes — store blank uses your first grocery store.</p>
<div id="mealGroceryRows" class="d-flex flex-column gap-2">
<?php
$shopItems = $prefillMeal['items'] ?? [];
if (!is_array($shopItems) || count($shopItems) === 0) {
$shopItems = [['name' => '', 'storeId' => '', 'quantity' => '1', 'size' => '', 'description' => '', 'price' => '', 'image' => '']];
}
foreach ($shopItems as $si):
?>
<div class="row g-2 align-items-end meal-grocery-row">
<div class="col-md-4">
<input class="form-control form-control-sm g-name" placeholder="Name" value="<?= sanitizeInput((string) ($si['name'] ?? '')) ?>">
</div>
<div class="col-md-3">
<select class="form-select form-select-sm g-store">
<option value="">Default store</option>
<?php foreach ($stores as $st): ?>
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" <?= (($si['storeId'] ?? '') === ($st['id'] ?? '')) ? 'selected' : '' ?>><?= sanitizeInput($st['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<input class="form-control form-control-sm g-qty" placeholder="Qty" value="<?= sanitizeInput((string) ($si['quantity'] ?? '1')) ?>">
</div>
<div class="col-md-3">
<input class="form-control form-control-sm g-size" placeholder="Size" value="<?= sanitizeInput((string) ($si['size'] ?? '')) ?>">
</div>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAddGroceryRow">Add shopping row</button>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Save meal</button>
<a href="?tab=meals" class="btn btn-link">Cancel</a>
</div>
<div class="col-12">
<div class="alert d-none" id="mealSaveFeedback" role="status"></div>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="mealSlotModalLabel">Choose a meal</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="slotPickerDay" value="">
<input type="hidden" id="slotPickerType" value="">
<div class="mb-2">
<label class="form-label">Filter by tag (optional)</label>
<select class="form-select" id="slotPickerFilter">
<option value="">All meals</option>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
</select>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="slotPickerPushGrocery" checked>
<label class="form-check-label" for="slotPickerPushGrocery">Add this meals shopping items to grocery (pending review)</label>
</div>
<ul class="list-group" id="slotPickerList"></ul>
<button type="button" class="btn btn-outline-danger btn-sm mt-2" id="slotPickerClear">Clear slot</button>
</div>
</div>
</div>
</div>
<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; ?>