Compare commits

...

4 Commits

Author SHA1 Message Date
44e6547a0a Added Photo Collage to Calendar TV View 2026-04-04 11:18:28 -04:00
dee4567f74 TV view: reduce clock font size to one-third
Made-with: Cursor
2026-04-04 01:36:02 -04:00
c8f1be4934 New Clock added to Calender Feature 2026-04-03 22:39:02 -04:00
03825c1048 Josh's Changes 2026-04-03 20:43:00 -04:00
27 changed files with 394 additions and 56 deletions

View File

@ -770,6 +770,30 @@ 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 { .calendar-tv-view .calendar-embed iframe {
min-height: 70vh; min-height: 70vh;
} }
@ -780,6 +804,191 @@ 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 (recipe) page: sections and flow */
.meal-detail-header { .meal-detail-header {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -8,6 +8,30 @@
var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api'; 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) { function postJson(path, body) {
return fetch(apiBase + path, { return fetch(apiBase + path, {
method: 'POST', method: 'POST',
@ -15,7 +39,8 @@
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify(body || {}) body: JSON.stringify(body || {})
}).then(function (res) { }).then(function (res) {
return res.json().then(function (data) { return res.text().then(function (text) {
var data = parseJsonResponseText(res, text);
if (!res.ok) { if (!res.ok) {
var err = new Error(data.error || res.statusText || 'Request failed'); var err = new Error(data.error || res.statusText || 'Request failed');
err.payload = data; err.payload = data;
@ -775,11 +800,15 @@
function readMealsLibrary() { function readMealsLibrary() {
var el = document.getElementById('mealsLibraryJson'); var el = document.getElementById('mealsLibraryJson');
if (!el || !el.textContent) { if (!el) {
return [];
}
var raw = (el.textContent || '').trim();
if (raw === '') {
return []; return [];
} }
try { try {
return JSON.parse(el.textContent); return JSON.parse(raw);
} catch (e) { } catch (e) {
return []; return [];
} }
@ -1032,7 +1061,8 @@
var safeId = String(mealId); var safeId = String(mealId);
fetch(apiBase + '/meal_export.php?mealId=' + encodeURIComponent(safeId), { credentials: 'same-origin' }) fetch(apiBase + '/meal_export.php?mealId=' + encodeURIComponent(safeId), { credentials: 'same-origin' })
.then(function (res) { .then(function (res) {
return res.json().then(function (data) { return res.text().then(function (text) {
var data = parseJsonResponseText(res, text);
return { res: res, data: data }; return { res: res, data: data };
}); });
}) })
@ -1083,7 +1113,11 @@
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function () { reader.onload = function () {
try { try {
mealImportParsed = JSON.parse(String(reader.result || '')); var raw = String(reader.result || '').trim();
if (raw === '') {
throw new Error('File is empty.');
}
mealImportParsed = JSON.parse(raw);
$prev.text(JSON.stringify(mealImportParsed, null, 2)); $prev.text(JSON.stringify(mealImportParsed, null, 2));
$('#btnMealImportSubmit').prop('disabled', false); $('#btnMealImportSubmit').prop('disabled', false);
if ($fb.length) { if ($fb.length) {

View File

@ -3,7 +3,9 @@ GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_here GOOGLE_CLIENT_SECRET=your_client_secret_here
GOOGLE_REDIRECT_URI=http://localhost/family-hub/auth/google/callback GOOGLE_REDIRECT_URI=http://localhost/family-hub/auth/google/callback
# Google Calendar # 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_ID=your_calendar_id_here GOOGLE_CALENDAR_ID=your_calendar_id_here
GOOGLE_CALENDAR_EMBED_CODE=your_embed_code_here GOOGLE_CALENDAR_EMBED_CODE=your_embed_code_here

View File

@ -28,7 +28,7 @@ function readJsonBody(): array {
function sendJson(array $payload, int $code = 200): void { function sendJson(array $payload, int $code = 200): void {
http_response_code($code); http_response_code($code);
echo json_encode($payload); echo json_encode($payload, familyHubJsonEncodeShellFlags());
exit; exit;
} }

View File

@ -0,0 +1,35 @@
<?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>

View File

@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/persona.php'; require_once __DIR__ . '/persona.php';
require_once __DIR__ . '/utils.php';
const CHORE_SCHEDULE_ONCE = 'once'; const CHORE_SCHEDULE_ONCE = 'once';
const CHORE_SCHEDULE_RECURRING = 'recurring'; const CHORE_SCHEDULE_RECURRING = 'recurring';
@ -32,11 +33,11 @@ function legacyAssigneeNameToIds(string $name, array $people): array {
if ($name === '') { if ($name === '') {
return []; return [];
} }
$lower = mb_strtolower($name, 'UTF-8'); $lower = familyHubStrLowerUtf8($name);
$ids = []; $ids = [];
foreach ($people as $p) { foreach ($people as $p) {
$pn = trim((string) ($p['name'] ?? '')); $pn = trim((string) ($p['name'] ?? ''));
if ($pn !== '' && mb_strtolower($pn, 'UTF-8') === $lower) { if ($pn !== '' && familyHubStrLowerUtf8($pn) === $lower) {
$ids[] = (string) $p['id']; $ids[] = (string) $p['id'];
} }
} }

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/utils.php';
function getDataDirectory(): string { function getDataDirectory(): string {
return __DIR__ . '/../data'; return __DIR__ . '/../data';
} }
@ -49,7 +51,7 @@ function writeJsonFile(string $filename, $data): bool {
fclose($fp); fclose($fp);
return false; return false;
} }
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $json = json_encode($data, JSON_PRETTY_PRINT | familyHubJsonEncodeShellFlags());
if ($json === false) { if ($json === false) {
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);

View File

@ -1,9 +1,11 @@
</main> </main>
<?php if (empty($isCalendarTvView)): ?>
<footer class="bg-light p-3 mt-4"> <footer class="bg-light p-3 mt-4">
<div class="container text-center"> <div class="container text-center">
<p>Family Hub &copy; <?= date('Y') ?></p> <p>Family Hub &copy; <?= date('Y') ?></p>
</div> </div>
</footer> </footer>
<?php endif; ?>
<!-- Bootstrap JS from CDN --> <!-- Bootstrap JS from CDN -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/utils.php';
/** /**
* @param mixed $raw * @param mixed $raw
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
@ -35,8 +37,8 @@ function normalizeCatalogList($raw): array {
} }
function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string { function groceryCatalogDedupeKey(string $storeId, string $name, string $size): string {
$n = mb_strtolower(trim($name), 'UTF-8'); $n = familyHubStrLowerUtf8(trim($name));
$s = mb_strtolower(trim($size), 'UTF-8'); $s = familyHubStrLowerUtf8(trim($size));
return $storeId . '|' . $n . '|' . $s; return $storeId . '|' . $n . '|' . $s;
} }

View File

@ -72,7 +72,8 @@
} }
$bodyStyle = implode('; ', $bodyStyleParts) . ';'; $bodyStyle = implode('; ', $bodyStyleParts) . ';';
?> ?>
<body class="family-hub-body <?= htmlspecialchars($headerThemeClass ?? 'header-tone-dark', ENT_QUOTES, 'UTF-8') ?>" style="<?= htmlspecialchars($bodyStyle, ENT_QUOTES, 'UTF-8') ?>"> <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'; ?>
<?php $hideTopHeader = !empty($isCalendarTvView); ?> <?php $hideTopHeader = !empty($isCalendarTvView); ?>
<?php if (!$hideTopHeader): ?> <?php if (!$hideTopHeader): ?>
<header class="app-header app-header-with-tabs text-white pt-3 px-3 pb-2 mb-0"> <header class="app-header app-header-with-tabs text-white pt-3 px-3 pb-2 mb-0">
@ -220,4 +221,9 @@
<script> <script>
window.familyHubApiBase = <?= json_encode($familyHubApiBase, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP) ?>; window.familyHubApiBase = <?= json_encode($familyHubApiBase, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP) ?>;
</script> </script>
<main class="container py-4"> <?php
$fhMainClasses = !empty($isCalendarTvView)
? 'container-fluid px-0 py-0'
: 'container py-4';
?>
<main class="<?= htmlspecialchars($fhMainClasses, ENT_QUOTES, 'UTF-8') ?>">

View File

@ -33,3 +33,29 @@ function familyHubWebApiBase(): string {
} }
return $sd . '/api'; 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
* &lt;script type="application/json"&gt; 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);
}

View File

@ -167,7 +167,7 @@ if (fhRelativeLuminance($themePalette['primary']) >= 0.62) {
include 'includes/header.php'; include 'includes/header.php';
?> ?>
<div class="<?= $isCalendarTvView ? 'container-fluid px-3 py-3' : 'container' ?>"> <div class="<?= $isCalendarTvView ? 'container-fluid calendar-tv-shell' : 'container' ?>">
<div class="tab-content" id="dashboardContentArea"> <div class="tab-content" id="dashboardContentArea">
<?php include 'tabs/' . $activeTab . '.php'; ?> <?php include 'tabs/' . $activeTab . '.php'; ?>
</div> </div>

View File

@ -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 | | 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_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 Googles chosen view options. | | **`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 Googles 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 Drive ### 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 | | 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**). | | **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**). |
| **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. | | **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 Googles API. |
| **Monthly bill reminders** | `calendar_bill_days` | JSON array of `{ "dayOfMonth": 131, "title": "Rent" }`. Shown on the agenda on that calendar day each month. | | **Monthly bill reminders** | `calendar_bill_days` | JSON array of `{ "dayOfMonth": 131, "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. **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.

View File

@ -11,7 +11,7 @@ $calId = defined('GOOGLE_CALENDAR_ID') ? trim((string) GOOGLE_CALENDAR_ID) : '';
*/ */
function familyHub_google_calendar_embed_url(string $raw): ?string { function familyHub_google_calendar_embed_url(string $raw): ?string {
$raw = trim($raw); $raw = trim($raw);
if ($raw === '') { if ($raw === '' || strcasecmp($raw, 'your_embed_code_here') === 0) {
return null; return null;
} }
$allowed = static function (string $u): bool { $allowed = static function (string $u): bool {
@ -34,7 +34,8 @@ function familyHub_google_calendar_embed_url(string $raw): ?string {
if ($allowed($raw)) { if ($allowed($raw)) {
return $raw; return $raw;
} }
if (preg_match('#<iframe\s[^>]*\ssrc\s*=\s*["\']([^"\']+)["\']#i', $raw, $m)) { // 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)) {
$u = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); $u = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $allowed($u) ? $u : null; return $allowed($u) ? $u : null;
} }
@ -50,13 +51,11 @@ if ($embedUrl === null && $calId !== '' && $calId !== 'your_calendar_id_here') {
[$rangeStart, $rangeEnd] = hubCalendarDefaultAgendaRange($familySettings); [$rangeStart, $rangeEnd] = hubCalendarDefaultAgendaRange($familySettings);
$events = hubCalendarAgendaEvents($rangeStart, $rangeEnd, $people, $familySettings); $events = hubCalendarAgendaEvents($rangeStart, $rangeEnd, $people, $familySettings);
[, $tzLocal] = familyHubCalendarContext($familySettings); [$calendarTzId, $tzLocal] = familyHubCalendarContext($familySettings);
$todayYmd = familyHubTodayYmdInTz($tzLocal); $todayYmd = familyHubTodayYmdInTz($tzLocal);
$twoWayMerge = !empty($familySettings['calendar_two_way_google']); $twoWayMerge = !empty($familySettings['calendar_two_way_google']);
$isTvView = isset($_GET['view']) && (string) $_GET['view'] === 'tv'; $isTvView = isset($_GET['view']) && (string) $_GET['view'] === 'tv';
$calendarBaseUrl = '?tab=calendar';
$calendarTvUrl = '?tab=calendar&view=tv'; $calendarTvUrl = '?tab=calendar&view=tv';
$lastRefreshedStamp = gmdate('Y-m-d H:i') . ' UTC';
$eventsByDate = []; $eventsByDate = [];
foreach ($events as $ev) { foreach ($events as $ev) {
@ -82,43 +81,33 @@ $iconByType = [
?> ?>
<div id="calendar" class="tab-content<?= $isTvView ? ' calendar-tv-view' : '' ?>"> <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"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h2 class="mb-0">Calendar</h2> <h2 class="mb-0">Calendar</h2>
<?php if ($isTvView): ?> <a href="<?= htmlspecialchars($calendarTvUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn btn-outline-secondary btn-sm" title="Open TV view dashboard">
<a href="<?= htmlspecialchars($calendarBaseUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn btn-outline-secondary btn-sm" title="Exit TV view"> <i class="fa fa-tv me-1" aria-hidden="true"></i>
<i class="fa fa-xmark me-1" aria-hidden="true"></i> Open TV view
Exit TV view </a>
</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> </div>
<p class="text-muted small mb-3"> <p class="text-muted small mb-3">Open TV view for a full-width dashboard without site chrome.</p>
<?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-secondary mb-3" role="status">
<div class="alert alert-info mb-3" role="status"> <strong>Google Calendar is not synced into Family Hub data.</strong>
<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. 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 endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="row g-4 calendar-tab-layout<?= $embedUrl !== null ? ' calendar-tab-layout-split' : '' ?>"> <div class="row <?= $isTvView ? 'g-3' : 'g-4' ?> calendar-tab-layout<?= $embedUrl !== null ? ' calendar-tab-layout-split' : '' ?>">
<div class="col-12 calendar-tab-google"> <div class="col-12 calendar-tab-google">
<h3 class="h5 mb-3">Google Calendar</h3> <h3 class="h5 mb-3"><?= $isTvView ? 'Calendar' : 'Google Calendar' ?></h3>
<?php if ($embedUrl === null): ?> <?php if ($embedUrl === null): ?>
<?php if (!$isTvView): ?> <?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>&lt;iframe&gt;</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> <p class="text-muted">Connect a shared family calendar by setting <strong>GOOGLE_CALENDAR_EMBED_CODE</strong> (embed URL or full <code>&lt;iframe&gt;</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 endif; ?>
<?php else: ?> <?php else: ?>
<?php if ($fromIdOnly && !$isTvView): ?> <?php if ($fromIdOnly && !$isTvView): ?>
@ -137,8 +126,16 @@ $iconByType = [
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="col-12 calendar-tab-agenda"> <div class="col-12 calendar-tab-agenda">
<h3 class="h5 mb-3">Family Hub agenda</h3> <h3 class="h5 mb-3"><?= $isTvView ? 'Weekly hub' : 'Family Hub agenda' ?></h3>
<p class="text-muted small mb-3"> <?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;">
<?= htmlspecialchars($rangeStart, ENT_QUOTES, 'UTF-8') ?> to <?= htmlspecialchars($rangeEnd, ENT_QUOTES, 'UTF-8') ?> <?= 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> · Times use <strong><?= htmlspecialchars(str_replace('_', ' ', (string) ($familySettings['timezone'] ?? '')), ENT_QUOTES, 'UTF-8') ?></strong>
</p> </p>
@ -183,8 +180,29 @@ $iconByType = [
</div> </div>
<?php if ($isTvView): ?> <?php if ($isTvView): ?>
<script> <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.setTimeout(function () {
window.location.reload(); window.location.reload();
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
})();
</script> </script>
<?php endif; ?> <?php endif; ?>

View File

@ -213,7 +213,7 @@ $activeCharityDonated = is_numeric($activeBankPerson['charity_donated_total'] ??
</div> </div>
</div> </div>
<div class="card-body p-0"> <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) ?></script> <script type="application/json" id="currencyLeaderboardPeriodData"><?= json_encode($leaderboardRowsByPeriod, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | familyHubJsonEncodeShellFlags()) ?></script>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0 leaderboard-table"> <table class="table table-striped mb-0 leaderboard-table">
<thead> <thead>

View File

@ -492,6 +492,6 @@ $mealEditorForbidden = $editMealId !== '' && !$showMealEditorPage;
'title' => (string) ($m['title'] ?? ''), 'title' => (string) ($m['title'] ?? ''),
'tags' => array_values($m['tags'] ?? []), 'tags' => array_values($m['tags'] ?? []),
]; ];
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script> }, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | familyHubJsonEncodeShellFlags()) ?></script>
<?php endif; ?> <?php endif; ?>

View File

@ -102,13 +102,14 @@ $permanenceOptions = [
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="col-md-6 d-flex align-items-end"> <div class="col-12">
<div class="form-check mb-2"> <div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="calendar_two_way_google" name="calendar_two_way_google" value="1" <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' : '' ?> <?= !empty($familySettings['calendar_two_way_google']) ? 'checked' : '' ?>
<?= !$canManage ? 'disabled' : '' ?>> <?= !$canManage ? 'disabled' : '' ?>>
<label class="form-check-label" for="calendar_two_way_google">Enable two-way Google Calendar sync</label> <label class="form-check-label" for="calendar_two_way_google">Notify me when two-way Google Calendar sync is available</label>
</div> </div>
<p class="small text-muted mb-0">Family Hub does not call Googles 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>
<div class="col-12"> <div class="col-12">
<label class="form-label">Monthly bill reminders</label> <label class="form-label">Monthly bill reminders</label>
@ -368,7 +369,7 @@ $permanenceOptions = [
]; ];
} }
?> ?>
<script type="application/json" id="settingsPeopleEditPayload"><?= json_encode($peopleEditPayload, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script> <script type="application/json" id="settingsPeopleEditPayload"><?= json_encode($peopleEditPayload, JSON_HEX_TAG | JSON_HEX_AMP | familyHubJsonEncodeShellFlags()) ?></script>
<div class="modal fade" id="editPersonModal" tabindex="-1" aria-labelledby="editPersonModalLabel" aria-hidden="true" data-bs-backdrop="false"> <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"> <div class="modal-dialog modal-dialog-scrollable">