Home Activities Schema Framework
Use conditional-logic schema generation to keep Home Activities markup aligned with real page state and avoid stale or invalid static templates.
- Google no longer shows Home activities rich results. On Jun 4, 2024 Google removed the documentation because the home activity feature no longer appears in Google Search results (changelog).
- Event and VideoObject markup for classes is still valid schema.org and still helps AI assistants and other consumers, but an online-only session earns no Google rich result: the Event documentation does not support virtual experiences with no real-world component.
- What to do instead: mark up sessions that take place at a physical venue as Event (they may also be streamed), mark up recordings as VideoObject, and emit nothing structured for live online-only sessions.
When To Use It
Google Search Central RetiredDocumentation changelogRetired feature. Google removed this documentation on Jun 4, 2024 (Search Central updates changelog). Classes and activities now use the regular Event documentation (Event) for in-person sessions and the Video documentation for recordings.
Key Implementation Documentation Highlights
- Status: retired. Documentation removed Jun 4, 2024; Google lists no Home activities required or recommended properties any more.
- Event, still supported for sessions at a physical venue: required name, startDate and location as a Place with a PostalAddress. VirtualLocation-only events are not supported, and the Event feature is limited to Australia, Brazil, Canada, Germany, India, Latin America, Spain, the UK and the US.
- Event recommended: description, endDate (not before startDate), eventStatus, image, offers (price, ISO 4217 priceCurrency, availability InStock, SoldOut or PreOrder, validFrom, url), organizer and performer.
- Event value rules: ISO 8601 dates with a UTC offset, a date only for all-day sessions, no placeholder midnight start times, and startDate kept when a session is cancelled, postponed or rescheduled.
- VideoObject for recordings: required name, thumbnailUrl and uploadDate (ISO 8601 DateTime with offset).
Template Approach
const pageData = {
title: "Saturday Vinyasa Flow",
start: "2026-11-07T09:00:00-05:00",
studio: "Example Yoga Studio",
street: "120 Moody Street",
city: "Waltham",
region: "MA"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Event",
"name": pageData.title,
"startDate": pageData.start,
"location": {
"@type": "Place",
"name": pageData.studio,
"address": {
"@type": "PostalAddress",
"streetAddress": pageData.street,
"addressLocality": pageData.city,
"addressRegion": pageData.region,
"addressCountry": "US"
}
}
};
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Saturday Vinyasa Flow",
"startDate": "2026-11-07T09:00:00-05:00",
"location": {
"@type": "Place",
"name": "Example Yoga Studio",
"address": {
"@type": "PostalAddress",
"streetAddress": "120 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"addressCountry": "US"
}
}
}
Conditional-logic Framework
// Shared helpers
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATETIME = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function toIsoDate(value) {
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
if (!match) return null;
const [y, m, d] = match.slice(1).map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null;
}
// Keeps the original timezone offset; rejects naive or unparseable values.
function toIsoDateTime(value) {
const match = typeof value === "string" ? ISO_DATETIME.exec(value) : null;
return match && toIsoDate(match[1]) ? value : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
// Page gates: indexable, self-canonical, marked-up content visible.
function passesPageGates(page) {
if (!page || page.noindex || page.robotsBlocked) return false;
if (!isAbsoluteUrl(page.url)) return false;
if (page.canonicalUrl && page.canonicalUrl !== page.url) return false;
return page.contentVisible === true;
}
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
const TICKET_AVAILABILITY = {
in_stock: "https://schema.org/InStock",
sold_out: "https://schema.org/SoldOut",
pre_order: "https://schema.org/PreOrder"
};
// Seconds to ISO 8601 duration, for example 2700 -> "PT45M".
function toIsoDuration(seconds) {
if (!Number.isInteger(seconds) || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return "PT" + (h ? h + "H" : "") + (m ? m + "M" : "") + (s ? s + "S" : "");
}
// Home activities rich results were removed Jun 4, 2024. What still qualifies:
// a scheduled session with a physical venue (Event, optionally also streamed),
// or a recording (VideoObject). Online-only live sessions get no markup type.
function chooseHomeActivityType(source) {
const venue = source.venue || {};
if (source.startDate && venue.name && venue.streetAddress) return "Event";
if (source.recording && source.recording.uploadDate) return "VideoObject";
return null;
}
function buildEvent(source) {
// Google Event required: name, startDate, location (Place with address).
const startDate = toIsoDateTime(source.startDate);
if (!source.name || !startDate) return null;
const endDate = toIsoDateTime(source.endDate);
const venue = source.venue;
const streamed = isAbsoluteUrl(source.streamUrl);
const offer = source.offer || {};
const price = toNumber(offer.price);
return {
"@context": "https://schema.org",
"@type": "Event",
name: source.name,
description: source.description,
startDate,
endDate: endDate && Date.parse(endDate) > Date.parse(startDate) ? endDate : null,
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: streamed
? "https://schema.org/MixedEventAttendanceMode"
: "https://schema.org/OfflineEventAttendanceMode",
// Google's Event doc requires location as a Place; VirtualLocation entries fail
// validation, so the stream link stays in the visible page content.
location: {
"@type": "Place",
name: venue.name,
address: {
"@type": "PostalAddress",
streetAddress: venue.streetAddress,
addressLocality: venue.locality,
addressRegion: venue.region,
postalCode: venue.postalCode,
addressCountry: /^[A-Z]{2}$/.test(venue.country || "") ? venue.country : null
}
},
image: (source.images || []).filter(isAbsoluteUrl),
offers: price !== null && price >= 0 && isCurrencyCode(offer.currency) ? {
"@type": "Offer",
price,
priceCurrency: offer.currency,
availability: TICKET_AVAILABILITY[offer.availability] || null,
validFrom: toIsoDateTime(offer.validFrom),
url: isAbsoluteUrl(offer.url) ? offer.url : null
} : null,
organizer: source.organizer && source.organizer.name ? {
"@type": "Organization",
name: source.organizer.name,
url: isAbsoluteUrl(source.organizer.url) ? source.organizer.url : null
} : null,
performer: source.instructor ? { "@type": "Person", name: source.instructor } : null
};
}
function buildRecording(source) {
// Google Video required: name, thumbnailUrl, uploadDate (DateTime with offset).
const rec = source.recording;
const thumbnails = (rec.thumbnailUrls || []).filter(isAbsoluteUrl);
const uploadDate = toIsoDateTime(rec.uploadDate);
if (!source.name || thumbnails.length === 0 || !uploadDate) return null;
return {
"@context": "https://schema.org",
"@type": "VideoObject",
name: source.name,
description: source.description,
thumbnailUrl: thumbnails,
uploadDate,
duration: toIsoDuration(rec.durationSeconds),
contentUrl: isAbsoluteUrl(rec.contentUrl) && rec.contentUrl !== source.page.url ? rec.contentUrl : null
};
}
function buildHomeActivitySchema(source) {
if (!passesPageGates(source.page)) return null;
const type = chooseHomeActivityType(source);
const schema = type === "Event" ? buildEvent(source) : type === "VideoObject" ? buildRecording(source) : null;
return schema ? compact(schema) : null;
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/classes/vinyasa-flow-2026-11-07",
// "canonicalUrl": "https://www.example.com/classes/vinyasa-flow-2026-11-07",
// "contentVisible": true
// },
// "name": "Saturday Vinyasa Flow",
// "description": "A 60-minute all-levels vinyasa class, taught in the studio and streamed live for students practicing at home.",
// "startDate": "2026-11-07T09:00:00-05:00",
// "endDate": "2026-11-07T10:00:00-05:00",
// "venue": {
// "name": "Example Yoga Studio",
// "streetAddress": "120 Moody Street",
// "locality": "Waltham",
// "region": "MA",
// "postalCode": "02453",
// "country": "US"
// },
// "streamUrl": "https://www.example.com/live/vinyasa-flow",
// "images": [
// "https://www.example.com/media/vinyasa-flow-16x9.jpg",
// "https://www.example.com/media/vinyasa-flow-4x3.jpg",
// "https://www.example.com/media/vinyasa-flow-1x1.jpg"
// ],
// "offer": {
// "price": 18,
// "currency": "USD",
// "availability": "in_stock",
// "validFrom": "2026-10-01T00:00:00-04:00",
// "url": "https://www.example.com/classes/vinyasa-flow-2026-11-07/book"
// },
// "organizer": {
// "name": "Example Yoga Studio",
// "url": "https://www.example.com/"
// },
// "instructor": "Maya Chen"
// };
// const schema = buildHomeActivitySchema(source);
# Shared helpers
import re
from datetime import date, datetime
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parts = urlparse(value)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.hostname)
def to_iso_date(value):
if not isinstance(value, str) or not ISO_DATE.match(value):
return None
try:
date.fromisoformat(value)
except ValueError:
return None
return value
# Keeps the original timezone offset; rejects naive or unparseable values.
def to_iso_datetime(value):
match = ISO_DATETIME.match(value) if isinstance(value, str) else None
return value if match and to_iso_date(match.group(1)) else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {key: kept for key, kept in ((k, compact(v)) for k, v in value.items()) if kept is not None}
return out or None
return None if value is None or value == "" else value
# Page gates: indexable, self-canonical, marked-up content visible.
def passes_page_gates(page):
if not page or page.get("noindex") or page.get("robotsBlocked"):
return False
if not is_absolute_url(page.get("url")):
return False
if page.get("canonicalUrl") and page["canonicalUrl"] != page["url"]:
return False
return page.get("contentVisible") is True
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
TICKET_AVAILABILITY = {
"in_stock": "https://schema.org/InStock",
"sold_out": "https://schema.org/SoldOut",
"pre_order": "https://schema.org/PreOrder",
}
# Seconds to ISO 8601 duration, for example 2700 -> "PT45M".
def to_iso_duration(seconds):
if not isinstance(seconds, int) or isinstance(seconds, bool) or seconds <= 0:
return None
h, rest = divmod(seconds, 3600)
m, s = divmod(rest, 60)
return "PT" + (f"{h}H" if h else "") + (f"{m}M" if m else "") + (f"{s}S" if s else "")
def parse_datetime(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Home activities rich results were removed Jun 4, 2024. What still qualifies:
# a scheduled session with a physical venue (Event, optionally also streamed),
# or a recording (VideoObject). Online-only live sessions get no markup type.
def choose_home_activity_type(source):
venue = source.get("venue") or {}
if source.get("startDate") and venue.get("name") and venue.get("streetAddress"):
return "Event"
if (source.get("recording") or {}).get("uploadDate"):
return "VideoObject"
return None
def build_event(source):
# Google Event required: name, startDate, location (Place with address).
start_date = to_iso_datetime(source.get("startDate"))
if not source.get("name") or not start_date:
return None
end_date = to_iso_datetime(source.get("endDate"))
venue = source["venue"]
streamed = is_absolute_url(source.get("streamUrl"))
offer = source.get("offer") or {}
price = to_number(offer.get("price"))
organizer = source.get("organizer") or {}
return {
"@context": "https://schema.org",
"@type": "Event",
"name": source["name"],
"description": source.get("description"),
"startDate": start_date,
"endDate": end_date if end_date and parse_datetime(end_date) > parse_datetime(start_date) else None,
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/MixedEventAttendanceMode"
if streamed else "https://schema.org/OfflineEventAttendanceMode",
# Google's Event doc requires location as a Place; VirtualLocation entries fail
# validation, so the stream link stays in the visible page content.
"location": {
"@type": "Place",
"name": venue["name"],
"address": {
"@type": "PostalAddress",
"streetAddress": venue["streetAddress"],
"addressLocality": venue.get("locality"),
"addressRegion": venue.get("region"),
"postalCode": venue.get("postalCode"),
"addressCountry": venue.get("country")
if re.fullmatch(r"[A-Z]{2}", venue.get("country") or "") else None,
},
},
"image": [url for url in source.get("images") or [] if is_absolute_url(url)],
"offers": {
"@type": "Offer",
"price": price,
"priceCurrency": offer["currency"],
"availability": TICKET_AVAILABILITY.get(offer.get("availability")),
"validFrom": to_iso_datetime(offer.get("validFrom")),
"url": offer.get("url") if is_absolute_url(offer.get("url")) else None,
} if price is not None and price >= 0 and is_currency_code(offer.get("currency")) else None,
"organizer": {
"@type": "Organization",
"name": organizer["name"],
"url": organizer.get("url") if is_absolute_url(organizer.get("url")) else None,
} if organizer.get("name") else None,
"performer": {"@type": "Person", "name": source["instructor"]} if source.get("instructor") else None,
}
def build_recording(source):
# Google Video required: name, thumbnailUrl, uploadDate (DateTime with offset).
rec = source["recording"]
thumbnails = [url for url in rec.get("thumbnailUrls") or [] if is_absolute_url(url)]
upload_date = to_iso_datetime(rec.get("uploadDate"))
if not source.get("name") or not thumbnails or not upload_date:
return None
content_url = rec.get("contentUrl")
return {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": source["name"],
"description": source.get("description"),
"thumbnailUrl": thumbnails,
"uploadDate": upload_date,
"duration": to_iso_duration(rec.get("durationSeconds")),
"contentUrl": content_url
if is_absolute_url(content_url) and content_url != source["page"]["url"] else None,
}
def build_home_activity_schema(source):
if not passes_page_gates(source.get("page")):
return None
type_ = choose_home_activity_type(source)
if type_ == "Event":
schema = build_event(source)
elif type_ == "VideoObject":
schema = build_recording(source)
else:
schema = None
return compact(schema) if schema else None
# Usage: json_ld = build_home_activity_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Saturday Vinyasa Flow",
"description": "A 60-minute all-levels vinyasa class, taught in the studio and streamed live for students practicing at home.",
"startDate": "2026-11-07T09:00:00-05:00",
"endDate": "2026-11-07T10:00:00-05:00",
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/MixedEventAttendanceMode",
"location": {
"@type": "Place",
"name": "Example Yoga Studio",
"address": {
"@type": "PostalAddress",
"streetAddress": "120 Moody Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
}
},
"image": [
"https://www.example.com/media/vinyasa-flow-16x9.jpg",
"https://www.example.com/media/vinyasa-flow-4x3.jpg",
"https://www.example.com/media/vinyasa-flow-1x1.jpg"
],
"offers": {
"@type": "Offer",
"price": 18,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"validFrom": "2026-10-01T00:00:00-04:00",
"url": "https://www.example.com/classes/vinyasa-flow-2026-11-07/book"
},
"organizer": {
"@type": "Organization",
"name": "Example Yoga Studio",
"url": "https://www.example.com/"
},
"performer": {
"@type": "Person",
"name": "Maya Chen"
}
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Routes each activity to the one type Google still supports for it (Event at a venue, VideoObject for a recording) and emits nothing for online-only live sessions.
- Gates on Event and Video required properties, so a class without a venue address or a timezone-qualified start emits nothing.
- Keeps the location a single Place, because Google's Event validator rejects VirtualLocation entries; the stream link stays in the visible page content.
- Drops an endDate that is not after startDate and an offer with no valid ISO 4217 currency, instead of publishing contradictory data.
Property Reference
Google retired the Home Activities rich result, so its documentation no longer lists required or recommended properties. The framework above is still valid schema.org markup and still describes the page to search engines and AI assistants.
Schema.org types used