Compare commits
No commits in common. "Armstrong_Branch" and "main" have entirely different histories.
Armstrong_
...
main
@ -770,30 +770,6 @@ body.header-tone-light .app-header .tab.active {
|
||||
}
|
||||
}
|
||||
|
||||
/* TV calendar: inset from screen edges (replaces tight container-fluid padding) */
|
||||
.calendar-tv-shell {
|
||||
padding: clamp(1.25rem, 3vw, 2.75rem);
|
||||
}
|
||||
|
||||
/* TV calendar: more space around the Google embed and between columns */
|
||||
.calendar-tv-view > .calendar-tab-layout.row {
|
||||
--bs-gutter-x: clamp(1.25rem, 2.5vw, 2.5rem);
|
||||
--bs-gutter-y: clamp(1.25rem, 2.5vw, 2.5rem);
|
||||
}
|
||||
|
||||
.calendar-tv-view .calendar-tab-google .calendar-embed {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-tv-clock-time {
|
||||
display: block;
|
||||
font-size: clamp(5.25rem, 12vw, 8.25rem);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calendar-tv-view .calendar-embed iframe {
|
||||
min-height: 70vh;
|
||||
}
|
||||
@ -804,191 +780,6 @@ body.header-tone-light .app-header .tab.active {
|
||||
}
|
||||
}
|
||||
|
||||
/* TV dashboard: dark room mode (wall display) */
|
||||
body.calendar-tv-dark {
|
||||
color-scheme: dark;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
--text-color: #e2e8f0;
|
||||
--border-color: #334155;
|
||||
--secondary-color: #1e293b;
|
||||
}
|
||||
|
||||
/* Photo collage behind TV calendar (fixed full viewport) */
|
||||
.calendar-tv-collage-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: clamp(6px, 1vw, 14px);
|
||||
padding: clamp(6px, 1vw, 14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(1) {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(2) {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(3) {
|
||||
grid-column: 4 / 5;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(4) {
|
||||
grid-column: 5 / 6;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(5) {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(6) {
|
||||
grid-column: 4 / 5;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(7) {
|
||||
grid-column: 5 / 6;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(8) {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 3 / 5;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(9) {
|
||||
grid-column: 3 / 5;
|
||||
grid-row: 3 / 5;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--mosaic .calendar-tv-collage-tile:nth-child(10) {
|
||||
grid-column: 5 / 6;
|
||||
grid-row: 3 / 5;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-grid--even .calendar-tv-collage-tile {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-tv-collage-tile {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.calendar-tv-collage-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(15, 23, 42, 0.78) 0%,
|
||||
rgba(15, 23, 42, 0.62) 45%,
|
||||
rgba(15, 23, 42, 0.75) 100%
|
||||
);
|
||||
}
|
||||
|
||||
body.calendar-tv-dark main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view h2,
|
||||
body.calendar-tv-dark .calendar-tv-view h3 {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .calendar-tv-clock-time {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .text-muted {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .calendar-embed {
|
||||
background-color: #1e293b;
|
||||
border-color: #334155 !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .calendar-embed.bg-white {
|
||||
background-color: #1e293b !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .list-group-item {
|
||||
background-color: rgba(30, 41, 59, 0.75);
|
||||
color: #e2e8f0;
|
||||
border-color: #334155 !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .list-group-item-action:hover,
|
||||
body.calendar-tv-dark .calendar-tv-view .list-group-item-action:focus {
|
||||
background-color: rgba(51, 65, 85, 0.65);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .calendar-agenda-today {
|
||||
background-color: color-mix(in srgb, var(--fh-primary, #4a90e2) 24%, #1e293b);
|
||||
border-color: var(--fh-primary, #60a5fa) !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .calendar-agenda-today .text-primary {
|
||||
color: var(--fh-primary, #93c5fd) !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .text-primary {
|
||||
color: var(--fh-primary, #93c5fd) !important;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .btn-outline-secondary {
|
||||
color: #cbd5e1;
|
||||
border-color: #64748b;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .btn-outline-secondary:hover,
|
||||
body.calendar-tv-dark .calendar-tv-view .btn-outline-secondary:focus {
|
||||
background-color: #334155;
|
||||
border-color: #94a3b8;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view code {
|
||||
background-color: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.calendar-tv-dark .calendar-tv-view .badge.bg-secondary {
|
||||
background-color: #475569 !important;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
|
||||
/* Meal detail (recipe) page: sections and flow */
|
||||
.meal-detail-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@ -8,30 +8,6 @@
|
||||
|
||||
var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api';
|
||||
|
||||
/**
|
||||
* Avoids JSON.parse on empty bodies (Firefox: "unexpected end of data at line 1 column 1")
|
||||
* and surfaces clearer errors when PHP returns HTML or nothing.
|
||||
*/
|
||||
function parseJsonResponseText(res, text) {
|
||||
var trimmed = (text || '').trim();
|
||||
if (trimmed === '') {
|
||||
var emptyErr = new Error(
|
||||
!res.ok ? (res.statusText || 'Request failed') : 'Empty response from server.'
|
||||
);
|
||||
emptyErr.payload = {};
|
||||
emptyErr.status = res.status;
|
||||
throw emptyErr;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
var parseErr = new Error('Server did not return JSON. Check server logs or network tab.');
|
||||
parseErr.payload = trimmed.slice(0, 500);
|
||||
parseErr.status = res.status;
|
||||
throw parseErr;
|
||||
}
|
||||
}
|
||||
|
||||
function postJson(path, body) {
|
||||
return fetch(apiBase + path, {
|
||||
method: 'POST',
|
||||
@ -39,8 +15,7 @@
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (res) {
|
||||
return res.text().then(function (text) {
|
||||
var data = parseJsonResponseText(res, text);
|
||||
return res.json().then(function (data) {
|
||||
if (!res.ok) {
|
||||
var err = new Error(data.error || res.statusText || 'Request failed');
|
||||
err.payload = data;
|
||||
@ -800,15 +775,11 @@
|
||||
|
||||
function readMealsLibrary() {
|
||||
var el = document.getElementById('mealsLibraryJson');
|
||||
if (!el) {
|
||||
return [];
|
||||
}
|
||||
var raw = (el.textContent || '').trim();
|
||||
if (raw === '') {
|
||||
if (!el || !el.textContent) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(el.textContent);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
@ -1061,8 +1032,7 @@
|
||||
var safeId = String(mealId);
|
||||
fetch(apiBase + '/meal_export.php?mealId=' + encodeURIComponent(safeId), { credentials: 'same-origin' })
|
||||
.then(function (res) {
|
||||
return res.text().then(function (text) {
|
||||
var data = parseJsonResponseText(res, text);
|
||||
return res.json().then(function (data) {
|
||||
return { res: res, data: data };
|
||||
});
|
||||
})
|
||||
@ -1113,11 +1083,7 @@
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
try {
|
||||
var raw = String(reader.result || '').trim();
|
||||
if (raw === '') {
|
||||
throw new Error('File is empty.');
|
||||
}
|
||||
mealImportParsed = JSON.parse(raw);
|
||||
mealImportParsed = JSON.parse(String(reader.result || ''));
|
||||
$prev.text(JSON.stringify(mealImportParsed, null, 2));
|
||||
$('#btnMealImportSubmit').prop('disabled', false);
|
||||
if ($fb.length) {
|
||||
|
||||
@ -3,9 +3,7 @@ GOOGLE_CLIENT_ID=your_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret_here
|
||||
GOOGLE_REDIRECT_URI=http://localhost/family-hub/auth/google/callback
|
||||
|
||||
# Google Calendar (Calendar tab embed). Replace placeholders—leaving your_calendar_id_here hides the embed.
|
||||
# Easiest: set GOOGLE_CALENDAR_ID to the Calendar ID from Google Calendar → Settings → Integrate calendar.
|
||||
# Optional: paste the embed URL or full <iframe> HTML into GOOGLE_CALENDAR_EMBED_CODE; wrap the whole value in "double quotes" if you have parsing issues.
|
||||
# Google Calendar
|
||||
GOOGLE_CALENDAR_ID=your_calendar_id_here
|
||||
GOOGLE_CALENDAR_EMBED_CODE=your_embed_code_here
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ function readJsonBody(): array {
|
||||
|
||||
function sendJson(array $payload, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode($payload, familyHubJsonEncodeShellFlags());
|
||||
echo json_encode($payload);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (empty($isCalendarTvView ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collageDir = dirname(__DIR__) . '/assets/images/calendar-tv-collage';
|
||||
$files = glob($collageDir . '/collage-*.png') ?: [];
|
||||
if ($files === []) {
|
||||
$files = glob($collageDir . '/collage-*.{jpg,jpeg,webp}', GLOB_BRACE) ?: [];
|
||||
}
|
||||
natcasesort($files);
|
||||
$files = array_values($files);
|
||||
if ($files === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mosaic = count($files) === 10;
|
||||
$gridClass = $mosaic ? 'calendar-tv-collage-grid calendar-tv-collage-grid--mosaic' : 'calendar-tv-collage-grid calendar-tv-collage-grid--even';
|
||||
$cols = 5;
|
||||
$rows = (int) max(1, ceil(count($files) / $cols));
|
||||
?>
|
||||
<div class="calendar-tv-collage-bg" aria-hidden="true">
|
||||
<div class="<?= htmlspecialchars($gridClass, ENT_QUOTES, 'UTF-8') ?>"<?= $mosaic ? '' : ' style="grid-template-columns: repeat(' . (int) $cols . ', 1fr); grid-template-rows: repeat(' . (int) $rows . ', 1fr);"' ?>>
|
||||
<?php foreach ($files as $absPath): ?>
|
||||
<?php
|
||||
$base = basename((string) $absPath);
|
||||
$url = 'assets/images/calendar-tv-collage/' . $base;
|
||||
?>
|
||||
<div class="calendar-tv-collage-tile" style="background-image: url('<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>')"></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="calendar-tv-collage-scrim"></div>
|
||||
</div>
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/persona.php';
|
||||
require_once __DIR__ . '/utils.php';
|
||||
|
||||
const CHORE_SCHEDULE_ONCE = 'once';
|
||||
const CHORE_SCHEDULE_RECURRING = 'recurring';
|
||||
@ -33,11 +32,11 @@ function legacyAssigneeNameToIds(string $name, array $people): array {
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
$lower = familyHubStrLowerUtf8($name);
|
||||
$lower = mb_strtolower($name, 'UTF-8');
|
||||
$ids = [];
|
||||
foreach ($people as $p) {
|
||||
$pn = trim((string) ($p['name'] ?? ''));
|
||||
if ($pn !== '' && familyHubStrLowerUtf8($pn) === $lower) {
|
||||
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) {
|
||||
$ids[] = (string) $p['id'];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/utils.php';
|
||||
|
||||
function getDataDirectory(): string {
|
||||
return __DIR__ . '/../data';
|
||||
}
|
||||
@ -51,7 +49,7 @@ function writeJsonFile(string $filename, $data): bool {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | familyHubJsonEncodeShellFlags());
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
</main>
|
||||
<?php if (empty($isCalendarTvView)): ?>
|
||||
<footer class="bg-light p-3 mt-4">
|
||||
<div class="container text-center">
|
||||
<p>Family Hub © <?= date('Y') ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Bootstrap JS from CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/utils.php';
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, mixed>>
|
||||
@ -37,8 +35,8 @@ function normalizeCatalogList($raw): array {
|
||||
}
|
||||
|
||||
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
|
||||
$n = familyHubStrLowerUtf8(trim($name));
|
||||
$s = familyHubStrLowerUtf8(trim($size));
|
||||
$n = mb_strtolower(trim($name), 'UTF-8');
|
||||
$s = mb_strtolower(trim($size), 'UTF-8');
|
||||
return $storeId . '|' . $n . '|' . $s;
|
||||
}
|
||||
|
||||
|
||||
@ -72,8 +72,7 @@
|
||||
}
|
||||
$bodyStyle = implode('; ', $bodyStyleParts) . ';';
|
||||
?>
|
||||
<body class="family-hub-body <?= htmlspecialchars($headerThemeClass ?? 'header-tone-dark', ENT_QUOTES, 'UTF-8') ?><?= !empty($isCalendarTvView) ? ' calendar-tv-dark' : '' ?>" style="<?= htmlspecialchars($bodyStyle, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php include __DIR__ . '/calendar_tv_collage_bg.php'; ?>
|
||||
<body class="family-hub-body <?= htmlspecialchars($headerThemeClass ?? 'header-tone-dark', ENT_QUOTES, 'UTF-8') ?>" style="<?= htmlspecialchars($bodyStyle, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php $hideTopHeader = !empty($isCalendarTvView); ?>
|
||||
<?php if (!$hideTopHeader): ?>
|
||||
<header class="app-header app-header-with-tabs text-white pt-3 px-3 pb-2 mb-0">
|
||||
@ -221,9 +220,4 @@
|
||||
<script>
|
||||
window.familyHubApiBase = <?= json_encode($familyHubApiBase, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP) ?>;
|
||||
</script>
|
||||
<?php
|
||||
$fhMainClasses = !empty($isCalendarTvView)
|
||||
? 'container-fluid px-0 py-0'
|
||||
: 'container py-4';
|
||||
?>
|
||||
<main class="<?= htmlspecialchars($fhMainClasses, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<main class="container py-4">
|
||||
|
||||
@ -33,29 +33,3 @@ function familyHubWebApiBase(): string {
|
||||
}
|
||||
return $sd . '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask for json_encode so pasted recipe text (or any user string) with malformed UTF-8
|
||||
* does not make json_encode return false. Without substitution, failed embeds emit an empty
|
||||
* <script type="application/json"> and the meals UI hits JSON.parse errors.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
function familyHubJsonEncodeShellFlags(): int {
|
||||
$f = JSON_UNESCAPED_UNICODE;
|
||||
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
|
||||
$f |= JSON_INVALID_UTF8_SUBSTITUTE;
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase for comparisons and keys. Uses mbstring when available; otherwise ASCII-only strtolower
|
||||
* so grocery/chore code does not fatal on hosts without ext-mbstring.
|
||||
*/
|
||||
function familyHubStrLowerUtf8(string $s): string {
|
||||
if (function_exists('mb_strtolower')) {
|
||||
return mb_strtolower($s, 'UTF-8');
|
||||
}
|
||||
return strtolower($s);
|
||||
}
|
||||
|
||||
@ -167,7 +167,7 @@ if (fhRelativeLuminance($themePalette['primary']) >= 0.62) {
|
||||
include 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="<?= $isCalendarTvView ? 'container-fluid calendar-tv-shell' : 'container' ?>">
|
||||
<div class="<?= $isCalendarTvView ? 'container-fluid px-3 py-3' : 'container' ?>">
|
||||
<div class="tab-content" id="dashboardContentArea">
|
||||
<?php include 'tabs/' . $activeTab . '.php'; ?>
|
||||
</div>
|
||||
|
||||
@ -51,7 +51,7 @@ The **Calendar** tab embeds your family calendar in the hub using these values (
|
||||
| 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. Leave the placeholder `your_embed_code_here` unchanged only if you use **`GOOGLE_CALENDAR_ID`** instead. If a long iframe line misbehaves in `.env`, wrap the whole value in **double quotes**. |
|
||||
| **`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
|
||||
|
||||
@ -111,7 +111,7 @@ These are **not** in `.env`; they live in `data/family_settings.json` and are ed
|
||||
| Key / UI | Purpose | Notes |
|
||||
| -------- | -------- | ----- |
|
||||
| **Family timezone** | US IANA zone (e.g. `America/Denver`) | Dropdown of US zones only. Drives **today** and date ranges on the **Calendar** tab agenda. Options are the standard PHP US timezone identifiers (e.g. **America/***, **Pacific/Honolulu**). |
|
||||
| **Notify when two-way Google Calendar sync is available** (`calendar_two_way_google`) | Preference for a future OAuth feature | Default off. **Does not enable sync today.** The Calendar tab still uses only the Google **iframe embed** from `.env` (if set). The Family Hub agenda is built from chores, meals, groceries, expenses, bills, and birthdays in this app—not from Google’s API. |
|
||||
| **Enable two-way Google Calendar sync** | Opt-in for future OAuth sync with Google | Default off. When off, the Calendar tab uses the Google **embed** from `.env` (if set) and the local agenda only—no merge API. When on, the app may show sync messaging; full OAuth is not required for the embed. |
|
||||
| **Monthly bill reminders** | `calendar_bill_days` | JSON array of `{ "dayOfMonth": 1–31, "title": "Rent" }`. Shown on the agenda on that calendar day each month. |
|
||||
|
||||
**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone.
|
||||
|
||||
@ -11,7 +11,7 @@ $calId = defined('GOOGLE_CALENDAR_ID') ? trim((string) GOOGLE_CALENDAR_ID) : '';
|
||||
*/
|
||||
function familyHub_google_calendar_embed_url(string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || strcasecmp($raw, 'your_embed_code_here') === 0) {
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$allowed = static function (string $u): bool {
|
||||
@ -34,8 +34,7 @@ function familyHub_google_calendar_embed_url(string $raw): ?string {
|
||||
if ($allowed($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
// Match iframe with src first or after other attributes (previous pattern required \s before src and failed on <iframe src="...">).
|
||||
if (preg_match('#<iframe\s[^>]*\s*src\s*=\s*["\']([^"\']+)["\']#i', $raw, $m)) {
|
||||
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;
|
||||
}
|
||||
@ -51,11 +50,13 @@ if ($embedUrl === null && $calId !== '' && $calId !== 'your_calendar_id_here') {
|
||||
|
||||
[$rangeStart, $rangeEnd] = hubCalendarDefaultAgendaRange($familySettings);
|
||||
$events = hubCalendarAgendaEvents($rangeStart, $rangeEnd, $people, $familySettings);
|
||||
[$calendarTzId, $tzLocal] = familyHubCalendarContext($familySettings);
|
||||
[, $tzLocal] = familyHubCalendarContext($familySettings);
|
||||
$todayYmd = familyHubTodayYmdInTz($tzLocal);
|
||||
$twoWayMerge = !empty($familySettings['calendar_two_way_google']);
|
||||
$isTvView = isset($_GET['view']) && (string) $_GET['view'] === 'tv';
|
||||
$calendarBaseUrl = '?tab=calendar';
|
||||
$calendarTvUrl = '?tab=calendar&view=tv';
|
||||
$lastRefreshedStamp = gmdate('Y-m-d H:i') . ' UTC';
|
||||
|
||||
$eventsByDate = [];
|
||||
foreach ($events as $ev) {
|
||||
@ -81,33 +82,43 @@ $iconByType = [
|
||||
?>
|
||||
|
||||
<div id="calendar" class="tab-content<?= $isTvView ? ' calendar-tv-view' : '' ?>">
|
||||
<?php if (!$isTvView): ?>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
|
||||
<h2 class="mb-0">Calendar</h2>
|
||||
<a href="<?= htmlspecialchars($calendarTvUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn btn-outline-secondary btn-sm" title="Open TV view dashboard">
|
||||
<i class="fa fa-tv me-1" aria-hidden="true"></i>
|
||||
Open TV view
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Open TV view for a full-width dashboard without site chrome.</p>
|
||||
|
||||
<div class="alert alert-secondary mb-3" role="status">
|
||||
<strong>Google Calendar is not synced into Family Hub data.</strong>
|
||||
The Google panel below is an embedded view from Google (live in your browser). The <strong>Family Hub agenda</strong> lists chores, meals, groceries, expenses, bill reminders, and birthdays from this app only—Google events never appear there until a future API connection exists.
|
||||
<?php if ($twoWayMerge): ?>
|
||||
<span class="d-block mt-1 small">You turned on “two-way sync” in settings; OAuth with Google is <strong>not implemented yet</strong>, so that option does not change behavior today.</span>
|
||||
<?php if ($isTvView): ?>
|
||||
<a href="<?= htmlspecialchars($calendarBaseUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn btn-outline-secondary btn-sm" title="Exit TV view">
|
||||
<i class="fa fa-xmark me-1" aria-hidden="true"></i>
|
||||
Exit TV view
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="<?= htmlspecialchars($calendarTvUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn btn-outline-secondary btn-sm" title="Open TV view dashboard">
|
||||
<i class="fa fa-tv me-1" aria-hidden="true"></i>
|
||||
Open TV view
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
<?php if ($isTvView): ?>
|
||||
TV view auto-refreshes every 10 minutes.
|
||||
<?php else: ?>
|
||||
Open TV view for a dedicated dashboard URL that refreshes every 10 minutes.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if ($isTvView): ?>
|
||||
<p class="small text-muted mb-3">Last refreshed: <strong><?= htmlspecialchars($lastRefreshedStamp, ENT_QUOTES, 'UTF-8') ?></strong></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($twoWayMerge): ?>
|
||||
<div class="alert alert-info mb-3" role="status">
|
||||
<strong>Two-way Google Calendar sync</strong> is enabled in Family settings. OAuth connection is not available in this version yet. Your Google view below (if configured) and the Family Hub agenda still work; full sync will require signing in with Google when supported.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row <?= $isTvView ? 'g-3' : 'g-4' ?> calendar-tab-layout<?= $embedUrl !== null ? ' calendar-tab-layout-split' : '' ?>">
|
||||
<div class="row g-4 calendar-tab-layout<?= $embedUrl !== null ? ' calendar-tab-layout-split' : '' ?>">
|
||||
<div class="col-12 calendar-tab-google">
|
||||
<h3 class="h5 mb-3"><?= $isTvView ? 'Calendar' : 'Google Calendar' ?></h3>
|
||||
<h3 class="h5 mb-3">Google Calendar</h3>
|
||||
<?php if ($embedUrl === null): ?>
|
||||
<?php if (!$isTvView): ?>
|
||||
<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: ?>
|
||||
<p class="text-muted small mb-0">Google Calendar not configured (set <code class="small">.env</code>).</p>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php if ($fromIdOnly && !$isTvView): ?>
|
||||
@ -126,16 +137,8 @@ $iconByType = [
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12 calendar-tab-agenda">
|
||||
<h3 class="h5 mb-3"><?= $isTvView ? 'Weekly hub' : 'Family Hub agenda' ?></h3>
|
||||
<?php if ($isTvView): ?>
|
||||
<div class="calendar-tv-clock mb-3" aria-hidden="true">
|
||||
<time id="calendarTvClock" class="calendar-tv-clock-time"></time>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$isTvView): ?>
|
||||
<p class="text-muted small mb-2">Hub data only (not Google Calendar).</p>
|
||||
<?php endif; ?>
|
||||
<p class="text-muted small mb-3 hide hidden" style="display: none;">
|
||||
<h3 class="h5 mb-3">Family Hub agenda</h3>
|
||||
<p class="text-muted small mb-3">
|
||||
<?= htmlspecialchars($rangeStart, ENT_QUOTES, 'UTF-8') ?> to <?= htmlspecialchars($rangeEnd, ENT_QUOTES, 'UTF-8') ?>
|
||||
· Times use <strong><?= htmlspecialchars(str_replace('_', ' ', (string) ($familySettings['timezone'] ?? '')), ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
</p>
|
||||
@ -180,29 +183,8 @@ $iconByType = [
|
||||
</div>
|
||||
<?php if ($isTvView): ?>
|
||||
<script>
|
||||
(function () {
|
||||
var tz = <?= json_encode($calendarTzId, JSON_UNESCAPED_UNICODE) ?>;
|
||||
var el = document.getElementById('calendarTvClock');
|
||||
function tick() {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
var now = new Date();
|
||||
var opts = {
|
||||
timeZone: tz,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
};
|
||||
el.textContent = new Intl.DateTimeFormat(undefined, opts).format(now);
|
||||
el.setAttribute('datetime', now.toISOString());
|
||||
}
|
||||
tick();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 10 * 60 * 1000);
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@ -213,7 +213,7 @@ $activeCharityDonated = is_numeric($activeBankPerson['charity_donated_total'] ??
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<script type="application/json" id="currencyLeaderboardPeriodData"><?= json_encode($leaderboardRowsByPeriod, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | familyHubJsonEncodeShellFlags()) ?></script>
|
||||
<script type="application/json" id="currencyLeaderboardPeriodData"><?= json_encode($leaderboardRowsByPeriod, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?></script>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0 leaderboard-table">
|
||||
<thead>
|
||||
|
||||
@ -492,6 +492,6 @@ $mealEditorForbidden = $editMealId !== '' && !$showMealEditorPage;
|
||||
'title' => (string) ($m['title'] ?? ''),
|
||||
'tags' => array_values($m['tags'] ?? []),
|
||||
];
|
||||
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | familyHubJsonEncodeShellFlags()) ?></script>
|
||||
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@ -102,14 +102,13 @@ $permanenceOptions = [
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check mb-1">
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="calendar_two_way_google" name="calendar_two_way_google" value="1"
|
||||
<?= !empty($familySettings['calendar_two_way_google']) ? 'checked' : '' ?>
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
<label class="form-check-label" for="calendar_two_way_google">Notify me when two-way Google Calendar sync is available</label>
|
||||
<label class="form-check-label" for="calendar_two_way_google">Enable two-way Google Calendar sync</label>
|
||||
</div>
|
||||
<p class="small text-muted mb-0">Family Hub does not call Google’s API yet. Your calendar appears on the Calendar tab only via <code class="small">.env</code> (<code>GOOGLE_CALENDAR_ID</code> or <code>GOOGLE_CALENDAR_EMBED_CODE</code>). This checkbox only stores a preference; it does not enable sync today.</p>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Monthly bill reminders</label>
|
||||
@ -369,7 +368,7 @@ $permanenceOptions = [
|
||||
];
|
||||
}
|
||||
?>
|
||||
<script type="application/json" id="settingsPeopleEditPayload"><?= json_encode($peopleEditPayload, JSON_HEX_TAG | JSON_HEX_AMP | familyHubJsonEncodeShellFlags()) ?></script>
|
||||
<script type="application/json" id="settingsPeopleEditPayload"><?= json_encode($peopleEditPayload, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
<div class="modal fade" id="editPersonModal" tabindex="-1" aria-labelledby="editPersonModalLabel" aria-hidden="true" data-bs-backdrop="false">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
|
||||