Skip to content

Commit 3d0235b

Browse files
committed
Adding more calendars and sync to demo and mock weather data.
1 parent a499eb5 commit 3d0235b

10 files changed

Lines changed: 255 additions & 23 deletions

File tree

client/src/app.jsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,10 +354,12 @@ const App = () => {
354354
chores: { enabled: true },
355355
calendar: { enabled: true },
356356
photos: { enabled: false },
357-
weather: { enabled: false },
357+
// Weather renders from the server's static demo snapshot
358+
// (/api/demo/weather) — no OpenWeatherMap key needed.
359+
weather: { enabled: true },
358360
},
359361
});
360-
for (const widgetName of ['chores', 'calendar']) {
362+
for (const widgetName of ['chores', 'calendar', 'weather']) {
361363
await axios.post(`${API_DEVICE_URL}/widget-assignments`, {
362364
widget_name: widgetName,
363365
tabNumber: 1,
@@ -782,6 +784,7 @@ const App = () => {
782784
activeTab={activeTab}
783785
activeTabConfigJson={tabs.find((tab) => tab.number === activeTab)?.config_json || null}
784786
allTabConfigs={tabs}
787+
demoMode={demoStatus.demo}
785788
/>
786789
</Suspense>
787790
),
@@ -847,7 +850,7 @@ const App = () => {
847850
});
848851

849852
return result;
850-
}, [widgetSettings, pluginSettings, activeTab, apiKeys, widgetAssignments, installedPlugins, theme]);
853+
}, [widgetSettings, pluginSettings, activeTab, apiKeys, widgetAssignments, installedPlugins, theme, demoStatus.demo]);
851854

852855
const activeTabId = useMemo(() => {
853856
const active = tabs.find(tab => tab.number === activeTab);

client/src/components/WeatherWidget.jsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const WeatherWidget = ({
5252
prefetchOnly = false,
5353
refreshNonce = 0,
5454
isActive = true,
55+
demoMode = false,
5556
}) => {
5657
const API_DEVICE_URL = getDeviceApiBase(API_BASE_URL);
5758
const [weatherData, setWeatherData] = useState(null);
@@ -537,6 +538,29 @@ const WeatherWidget = ({
537538
applyResolvedTabSettings(tabSettings);
538539
}, [activeTab, activeTabConfigJson, refreshNonce]);
539540

541+
// Demo mode: no OpenWeatherMap key exists, so instead of the normal fetch
542+
// pipeline the server hands us a static snapshot (Chili, NY). Writing it into
543+
// the payload cache means every later settings re-apply finds it there.
544+
useEffect(() => {
545+
if (!demoMode || !settingsLoaded) return undefined;
546+
547+
let cancelled = false;
548+
(async () => {
549+
try {
550+
const units = tempUnit === 'C' ? 'metric' : 'imperial';
551+
const response = await axios.get(`${API_BASE_URL}/api/demo/weather?units=${units}`);
552+
if (cancelled || !response.data?.weatherData) return;
553+
writeCachedPayloadFor(locationQuery, tempUnit, response.data);
554+
applyWeatherPayloadToState(response.data);
555+
setError(null);
556+
} catch {
557+
// Demo snapshot unavailable; leave the widget in its default state.
558+
}
559+
})();
560+
561+
return () => { cancelled = true; };
562+
}, [demoMode, settingsLoaded, locationQuery, tempUnit]);
563+
540564
useEffect(() => {
541565
if (!settingsLoaded || !shouldFetchNow) {
542566
return;

docs/guides/demo-mode.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ DEMO MODE enabled: in-memory database, PIN disabled, sample data resets every 6h
4343
| --- | --- | --- |
4444
| **Database** | SQLite file at `server/data/tasks.db` (persistent) | **In-memory** — wiped when the container stops; `DB_PATH` is ignored |
4545
| **Admin PIN** | Optional PIN gate on the Admin Panel | **Disabled** — panel opens freely; PIN set/verify/delete return `403` so no visitor can lock others out |
46-
| **Sample data** | Empty first-run welcome screen | A demo household is **seeded at boot and re-seeded every 6 hours**; new visitor devices auto-enable the chore + calendar widgets |
46+
| **Sample data** | Empty first-run welcome screen | A demo household is **seeded at boot and re-seeded every 6 hours**; new visitor devices auto-enable the chore, calendar, and weather widgets |
47+
| **Calendar sync** | Syncs whatever sources you configure | Runs **only for the curated demo feeds** seeded at boot; source add/edit/delete routes are blocked so visitor URLs are never fetched |
48+
| **Weather** | Live OpenWeatherMap data (your API key) | A **static snapshot of Chili, NY** served from `GET /api/demo/weather` — no API key involved |
4749
| **Abuse-prone routes** | Available | Return `403` (see below) |
4850
| **Client banner** | — | A **"Demo Mode — sample data resets every N hours"** banner is shown |
4951

@@ -58,9 +60,10 @@ mode." }`:
5860
- **The `/api/proxy` CORS proxy** — so it can't be used as an open relay.
5961
- **Google & Apple connection setup** — OAuth config, authorize/callback, account
6062
delete, CalDAV calendar listing.
61-
- **Calendar test & sync triggers** — visitor-supplied calendar URLs are **never
62-
fetched**; the calendar sync service stays off entirely and the seeded events
63-
are shown from cache.
63+
- **Calendar source management and manual sync triggers** — adding, editing, or
64+
deleting calendar sources is blocked, as are the test/sync endpoints. The sync
65+
service *does* run in demo mode, but only ever fetches the curated feeds
66+
seeded below — a visitor-supplied URL can never reach it (SSRF guard).
6467

6568
Everyday interactive routes **still work**, so the demo feels live: completing and
6669
un-completing chores, moving and resizing widgets, switching tabs, and adjusting
@@ -86,13 +89,25 @@ and is designed to make an empty dashboard feel like a real family command cente
8689
chores post clams, routine chores post zero.
8790
- **Prizes** to spend clams on — Movie night pick (50), Ice cream trip (30),
8891
30 min extra screen time (15).
89-
- **A week of calendar events** — soccer practice, pizza night, a dentist
92+
- **A week of family calendar events** — soccer practice, pizza night, a dentist
9093
appointment, an overnight trip to Grandma's, a piano recital, and a
91-
library-books-due reminder. Calendar sync never runs; these come from the event
92-
cache under a placeholder "Family Calendar" source that is never fetched.
94+
library-books-due reminder. These are baked into the event cache under a
95+
placeholder "Family Calendar" source (a reserved `.invalid` URL the sync
96+
service knows to skip), so they're on screen the instant the demo boots.
97+
- **Four live public calendar feeds**, synced for real by the calendar sync
98+
service so the demo shows genuine multi-calendar behavior:
99+
- **US Federal Holidays** (OPM)
100+
- **Arizona Diamondbacks** (MLB schedule)
101+
- **Town of Chili — Calendar** and **Town of Chili — Community Events** — two
102+
overlapping town feeds, which double as a live demonstration of
103+
cross-calendar event deduplication.
104+
- **A weather snapshot for Chili, NY** — the demo has no OpenWeatherMap key, so
105+
the weather widget renders a static real-conditions snapshot (current temp,
106+
3-day outlook, hourly chart, air quality) served by `GET /api/demo/weather`.
93107

94108
Because the whole set is re-seeded on a timer (every 6 hours by default) and the
95-
database is in-memory, the demo always returns to this known-good state.
109+
database is in-memory, the demo always returns to this known-good state; each
110+
reset also restarts the feed sync jobs and refetches the live calendars.
96111

97112
## How the reset works
98113

docs/guides/deployment.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,14 @@ What it changes:
7373
- **Admin PIN disabled** — the Admin Panel opens without a prompt, and the
7474
PIN set/verify/delete routes return 403 so a visitor can't lock others out.
7575
- **Sample data** — a demo household (users, chores, schedules, clam history,
76-
prizes, a week of calendar events) is seeded at boot and re-seeded every
77-
6 hours; new visitor devices auto-enable the chore + calendar widgets.
76+
prizes, family calendar events, live public calendar feeds, and a static
77+
weather snapshot) is seeded at boot and re-seeded every 6 hours; new visitor
78+
devices auto-enable the chore, calendar, and weather widgets.
7879
- **Abuse-prone routes return 403** — all uploads (widgets/sounds/avatars/
7980
photos), widget install/delete, the `/api/proxy` CORS proxy, Google/Apple
80-
connection setup, and calendar test/sync triggers (visitor-supplied URLs
81-
are never fetched — the calendar sync service stays off).
81+
connection setup, and all calendar source add/edit/delete/test/sync routes.
82+
Calendar sync runs, but only for the seeded demo feeds — visitor-supplied
83+
URLs are never fetched.
8284
- A "Demo Mode" banner is shown in the client.
8385

8486
Normal installs are unaffected: `DEMO_MODE` defaults to `false` everywhere.

docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ database). This page covers both.
1616
| `NODE_ENV` || `production` / `development`. |
1717
| `HOMEGLOW_DISABLE_BACKGROUND_JOBS` | `0` | Set to `1` to disable the nightly chore-pruning cron (useful in tests). |
1818
| `HOMEGLOW_DISABLE_CALENDAR_SYNC` | `0` | Set to `1` to disable the calendar sync service. |
19-
| `DEMO_MODE` | `false` | Set to `true` to run a **public demo instance**: in-memory DB (wiped on stop), admin PIN disabled, sample data seeded and reset every 6h, and abuse-prone routes (uploads, CORS proxy, OAuth, outbound calendar fetches) return 403. See the [Demo Mode](../guides/demo-mode.md) guide. |
19+
| `DEMO_MODE` | `false` | Set to `true` to run a **public demo instance**: in-memory DB (wiped on stop), admin PIN disabled, sample data seeded and reset every 6h (incl. live demo calendar feeds and a static weather snapshot), and abuse-prone routes (uploads, CORS proxy, OAuth, calendar source management) return 403 — calendar sync only ever fetches the seeded demo feeds. See the [Demo Mode](../guides/demo-mode.md) guide. |
2020
| `BACKEND_VERSION` / `BACKEND_GIT_COMMIT` / `BACKEND_GITHUB_REPOSITORY` | build metadata | Surfaced by `GET /api/stats`; set by CI. |
2121

2222
If `ENCRYPTION_KEY` is missing or invalid, the server logs a warning at startup and

server/index.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2501,6 +2501,16 @@ fastify.get('/api/demo', async () => {
25012501
return { demo: DEMO_MODE, resetHours: DEMO_MODE ? DEMO_RESET_HOURS : null };
25022502
});
25032503

2504+
// Static weather snapshot for the demo weather widget (no OpenWeatherMap key
2505+
// in demo mode). ?units=metric converts the imperial snapshot.
2506+
fastify.get('/api/demo/weather', async (request, reply) => {
2507+
if (!DEMO_MODE) {
2508+
return reply.status(404).send({ error: 'Not available outside demo mode.' });
2509+
}
2510+
const { buildDemoWeatherPayload } = require('./utils/demoWeather');
2511+
return buildDemoWeatherPayload(request.query.units === 'metric' ? 'metric' : 'imperial');
2512+
});
2513+
25042514
function deserializeSettingValue(value) {
25052515
if (typeof value !== 'string') {
25062516
return value;
@@ -3570,6 +3580,9 @@ fastify.get('/api/calendar-sources', async (request, reply) => {
35703580
});
35713581

35723582
fastify.post('/api/calendar-sources', async (request, reply) => {
3583+
// Demo-blocked: with the sync service running in demo mode, a visitor-added
3584+
// source would be fetched server-side (SSRF). Only the seeded feeds sync.
3585+
if (demoBlocked(reply)) return;
35733586
const { name, type, url, username, password, color } = request.body;
35743587
if (!name || !type || !url) {
35753588
return reply.status(400).send({ error: 'Name, type, and URL are required.' });
@@ -3606,6 +3619,9 @@ fastify.post('/api/calendar-sources', async (request, reply) => {
36063619
});
36073620

36083621
fastify.patch('/api/calendar-sources/:id', async (request, reply) => {
3622+
// Demo-blocked: editing a source's URL would make the sync service fetch a
3623+
// visitor-supplied address (SSRF).
3624+
if (demoBlocked(reply)) return;
36093625
const { id } = request.params;
36103626
const { name, type, url, username, password, color, enabled } = request.body;
36113627

@@ -3664,6 +3680,8 @@ fastify.patch('/api/calendar-sources/:id', async (request, reply) => {
36643680
});
36653681

36663682
fastify.delete('/api/calendar-sources/:id', async (request, reply) => {
3683+
// Demo-blocked so one visitor can't remove the curated demo feeds for the next.
3684+
if (demoBlocked(reply)) return;
36673685
const { id } = request.params;
36683686
try {
36693687
if (calendarSyncService) {
@@ -4612,6 +4630,13 @@ const start = async () => {
46124630
setInterval(() => {
46134631
try {
46144632
resetDemoData(db);
4633+
// Reseeding assigns new source ids, so the running sync jobs point
4634+
// at rows that no longer exist. Restart them (this also kicks off an
4635+
// immediate syncAllSources to refill the wiped event cache).
4636+
if (calendarSyncService && process.env.HOMEGLOW_DISABLE_CALENDAR_SYNC !== '1') {
4637+
calendarSyncService.stopAllSyncJobs();
4638+
calendarSyncService.startAllSyncJobs();
4639+
}
46154640
} catch (err) {
46164641
console.error('Demo reset failed:', err);
46174642
}
@@ -4633,13 +4658,19 @@ const start = async () => {
46334658
// Seed bundled default chore notification sounds into the persisted uploads volume
46344659
await seedDefaultSounds();
46354660

4636-
// Initialize calendar sync service. Demo mode constructs it (so cached
4637-
// demo events are readable) but never initializes the sync jobs, and the
4638-
// manual-sync routes are demo-blocked — visitor-entered calendar URLs are
4639-
// never fetched (SSRF guard).
4661+
// Initialize calendar sync service. Demo mode runs sync too, but ONLY for
4662+
// the curated feeds in demoSeed.js: every route that could add or edit a
4663+
// source URL is demo-blocked, so visitor-entered addresses are never
4664+
// fetched (SSRF guard). The seeded "Family Calendar" placeholder
4665+
// (.invalid host) is skipped by the service itself.
46404666
if (DEMO_MODE) {
46414667
calendarSyncService = new CalendarSyncService(db, decryptPassword);
4642-
console.log('Calendar sync jobs disabled in demo mode (cached events only)');
4668+
if (process.env.HOMEGLOW_DISABLE_CALENDAR_SYNC !== '1') {
4669+
calendarSyncService.initialize();
4670+
console.log('Calendar sync enabled in demo mode (seeded demo feeds only; source management is demo-blocked)');
4671+
} else {
4672+
console.log('Calendar sync jobs disabled in demo mode by HOMEGLOW_DISABLE_CALENDAR_SYNC=1 (cached events only)');
4673+
}
46434674
} else if (process.env.HOMEGLOW_DISABLE_CALENDAR_SYNC !== '1') {
46444675
calendarSyncService = new CalendarSyncService(db, decryptPassword);
46454676
calendarSyncService.initialize();

server/services/calendarSync.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ class CalendarSyncService {
5757
return { success: false, error: 'Source not found or disabled' };
5858
}
5959

60+
// Placeholder sources on the RFC 2606 reserved ".invalid" TLD can never
61+
// resolve (the demo's baked "Family Calendar" uses one to carry its
62+
// pre-cached events). Skip them silently instead of recording a
63+
// misleading error status.
64+
if (this.isPlaceholderUrl(source.url)) {
65+
return { skipped: true };
66+
}
67+
6068
console.log(`Starting sync for calendar source: ${source.name} (${source.type})`);
6169
const startTime = Date.now();
6270

@@ -249,6 +257,14 @@ class CalendarSyncService {
249257
return out;
250258
}
251259

260+
isPlaceholderUrl(url) {
261+
try {
262+
return new URL(url).hostname.toLowerCase().endsWith('.invalid');
263+
} catch {
264+
return false;
265+
}
266+
}
267+
252268
async syncAllSources() {
253269
const sources = this.db.prepare('SELECT id FROM calendar_sources WHERE enabled = 1').all();
254270
const results = [];

server/tests/demoMode.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ test.before(async () => {
7070
DEMO_MODE: 'true',
7171
TZ: 'UTC',
7272
HOMEGLOW_DISABLE_BACKGROUND_JOBS: '1',
73+
// Demo mode syncs its curated calendar feeds; keep tests offline.
74+
HOMEGLOW_DISABLE_CALENDAR_SYNC: '1',
7375
ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'),
7476
},
7577
stdio: ['ignore', 'pipe', 'pipe'],
@@ -142,6 +144,29 @@ test('sample data is seeded (users, chores, prizes, calendar events)', async ()
142144
const events = await api('/api/calendar-events');
143145
assert.equal(events.status, 200);
144146
assert.ok(events.body.length >= 4);
147+
148+
// Family Calendar placeholder + the 4 curated live ICS feeds.
149+
const sources = await api('/api/calendar-sources');
150+
assert.equal(sources.status, 200);
151+
assert.ok(sources.body.length >= 5);
152+
const sourceNames = sources.body.map((s) => s.name);
153+
assert.ok(sourceNames.includes('US Federal Holidays'));
154+
assert.ok(sourceNames.includes('Arizona Diamondbacks'));
155+
});
156+
157+
test('demo weather snapshot is served in both unit systems', async () => {
158+
const imperial = await api('/api/demo/weather');
159+
assert.equal(imperial.status, 200);
160+
assert.equal(imperial.body.weatherData.name, 'Chili');
161+
assert.equal(typeof imperial.body.weatherData.main.temp, 'number');
162+
assert.ok(Array.isArray(imperial.body.forecastData) && imperial.body.forecastData.length === 3);
163+
assert.ok(Array.isArray(imperial.body.chartData) && imperial.body.chartData.length === 8);
164+
assert.ok(imperial.body.airQualityData.list[0].main.aqi >= 1);
165+
166+
const metric = await api('/api/demo/weather?units=metric');
167+
assert.equal(metric.status, 200);
168+
assert.ok(metric.body.weatherData.main.temp < imperial.body.weatherData.main.temp,
169+
'metric temperature should be the Celsius conversion of the imperial snapshot');
145170
});
146171

147172
test('abuse-prone routes are blocked with 403', async () => {
@@ -158,6 +183,9 @@ test('abuse-prone routes are blocked with 403', async () => {
158183
['GET', '/api/connections/google/callback'],
159184
['DELETE', '/api/connections/google/account'],
160185
['POST', '/api/connections/apple/calendars'],
186+
['POST', '/api/calendar-sources'],
187+
['PATCH', '/api/calendar-sources/1'],
188+
['DELETE', '/api/calendar-sources/1'],
161189
['POST', '/api/calendar-sources/1/test'],
162190
['POST', '/api/photo-sources/1/uploaded'],
163191
['POST', '/api/calendar-sync/1'],

server/utils/demoSeed.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,32 @@ function seedUsersAndChores(db) {
126126
insertPrize.run('30 min extra screen time', 15);
127127
}
128128

129+
// Real public ICS feeds synced live in demo mode (calendar sync runs for these;
130+
// visitors can't add/edit sources — those routes are demo-blocked, so only this
131+
// curated list is ever fetched). Several overlap on purpose: the two Town of
132+
// Chili feeds share events, which shows off cross-calendar deduplication.
133+
const DEMO_CALENDAR_FEEDS = [
134+
{ name: 'US Federal Holidays', url: 'https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/holidays.ics', color: '#2a9d8f' },
135+
{ name: 'Arizona Diamondbacks', url: 'https://ics.calendarlabs.com/99/df8470ee/Arizona_Diamondbacks_-_MLB.ics', color: '#a4133c' },
136+
{ name: 'Town of Chili — Calendar', url: 'https://www.chiliny.gov/common/modules/iCalendar/iCalendar.aspx?catID=14&feed=calendar', color: '#457b9d' },
137+
{ name: 'Town of Chili — Community Events', url: 'https://www.chiliny.gov/common/modules/iCalendar/iCalendar.aspx?catID=30&feed=calendar', color: '#8338ec' },
138+
];
139+
129140
function seedCalendar(db) {
130-
// Calendar sync never runs in demo mode, so this ICS source is only a label
131-
// for the cached events below (the URL is never fetched).
141+
// The Family Calendar's .invalid URL is a placeholder that is never fetched
142+
// (the sync service skips reserved-TLD hosts); its events are the baked
143+
// cache entries below, so the demo has content the instant it boots.
132144
const sourceId = db.prepare(
133145
"INSERT INTO calendar_sources (name, type, url, color, enabled, sort_order) VALUES ('Family Calendar', 'ICS', 'https://demo.invalid/family.ics', '#6e44ff', 1, 0)"
134146
).run().lastInsertRowid;
135147

148+
const insertSource = db.prepare(
149+
'INSERT INTO calendar_sources (name, type, url, color, enabled, sort_order) VALUES (?, ?, ?, ?, 1, ?)'
150+
);
151+
DEMO_CALENDAR_FEEDS.forEach((feed, index) => {
152+
insertSource.run(feed.name, 'ICS', feed.url, feed.color, index + 1);
153+
});
154+
136155
const insertEvent = db.prepare(
137156
'INSERT INTO calendar_events_cache (source_id, event_uid, title, start_time, end_time, description, location, all_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
138157
);

0 commit comments

Comments
 (0)