diff --git a/contents/handbook/company/goal-setting.md b/contents/handbook/company/goal-setting.md index ea043db001ed..1ab8665fe465 100644 --- a/contents/handbook/company/goal-setting.md +++ b/contents/handbook/company/goal-setting.md @@ -84,7 +84,7 @@ If you aren't on a product team, replace 'product' with the equivalent 'thing' o When a team has set their quarterly goals it is the responsibility of the [team lead](/handbook/company/small-teams#what-is-the-role-of-the-team-lead) to document the goals on their team page, [publicly](/handbook/values). This enables teams to see what each other are working on, helps us hold teams accountable to their goals, and creates a shared sense of urgency and direction. -You can easily see what goals teams have set on the [WIP](/wip) page, which pulls all goals from the respective team pages. +You can easily see what goals teams have set on the [roadmap](/roadmap) page, which pulls all goals from the respective team pages. Teams can choose to document their goals publicly in a number of formats, but below is a useful template for getting started. Individuals may also choose to create planning issues to track their work in greater detail. diff --git a/contents/handbook/engineering/posthog-com/changelog.mdx b/contents/handbook/engineering/posthog-com/changelog.mdx index 4a45bbf34a82..ea2ec1a91dde 100644 --- a/contents/handbook/engineering/posthog-com/changelog.mdx +++ b/contents/handbook/engineering/posthog-com/changelog.mdx @@ -6,7 +6,7 @@ The [changelog](/changelog) publishing process is run by the `https://us.posthog.com/settings/user-feature-previews#${flagKey}` @@ -92,9 +95,15 @@ interface SqueakTeamNode { slug: string name: string miniCrest?: Parameters[0] + crest?: { publicId?: string } + objectives?: { body?: string } | null profiles?: { data?: SqueakProfileNode[] } } +interface GridTeam extends QuarterGridTeam { + objectivesBody?: string +} + type ChipSize = 'sm' | 'md' const CHIP_SIZE_CLASSES: Record = { @@ -161,13 +170,19 @@ const slugify = (text: string): string => .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') -const roadmapUrl = (search: string, feature?: string): string => { +/** Canonical URLs carry at most one drawer param: `feature` or `team`. Setting one clears the other. */ +const roadmapUrl = (search: string, drawer: { feature?: string; team?: string } = {}): string => { const params = new URLSearchParams(search) - if (feature) { - params.set('feature', feature) + if (drawer.feature) { + params.set('feature', drawer.feature) } else { params.delete('feature') } + if (drawer.team) { + params.set('team', drawer.team) + } else { + params.delete('team') + } const query = params.toString() return `/roadmap${query ? `?${query}` : ''}` } @@ -176,7 +191,7 @@ const CopyLinkButton = ({ flagKey }: { flagKey: string }): JSX.Element => { const [copied, setCopied] = useState(false) const copy = () => { - const url = `${window.location.origin}${roadmapUrl('', flagKey)}` + const url = `${window.location.origin}${roadmapUrl('', { feature: flagKey })}` navigator.clipboard?.writeText(url).then(() => { setCopied(true) window.setTimeout(() => setCopied(false), 2000) @@ -392,6 +407,7 @@ const FeatureCard = ({ isNew, isPopular, onClick, + showStage = false, }: { feature: EarlyAccessFeature team?: TeamInfo @@ -400,6 +416,8 @@ const FeatureCard = ({ isNew: boolean isPopular: boolean onClick: () => void + /** Show the stage chip instead of the team line — for contexts where the team is already known (e.g. the team drawer). */ + showStage?: boolean }): JSX.Element => { const crest = team?.miniCrest ? getImage(team.miniCrest) : undefined @@ -417,9 +435,13 @@ const FeatureCard = ({ {feature.name} - - {team ? `${team.name} Team` : teamSlug ? `${teamSlug} Team` : 'Unassigned'} - + {showStage ? ( + + ) : ( + + {team ? `${team.name} Team` : teamSlug ? `${teamSlug} Team` : 'Unassigned'} + + )} @@ -628,12 +650,15 @@ const FeaturePanel = ({ people, isNew, isPopular, + onTeamClick, }: { feature: EarlyAccessFeature teamSlug?: string people: TeamPerson[] isNew: boolean isPopular: boolean + /** Present when the owning team has a card in the quarter grid — swaps the drawer to that team. */ + onTeamClick?: (slug: string) => void }): JSX.Element => (
@@ -663,6 +688,18 @@ const FeaturePanel = ({

Built by

+ {onTeamClick && ( +
+ onTeamClick(teamSlug)} + > + View team goals + +
+ )}
)} @@ -687,6 +724,7 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { const [teamFilter, setTeamFilter] = useState('all') const [pitchOpen, setPitchOpen] = useState(false) const [selectedFlagKey, setSelectedFlagKey] = useState() + const [selectedTeamSlug, setSelectedTeamSlug] = useState() const [mounted, setMounted] = useState(false) const [overlayContainer, setOverlayContainer] = useState(null) @@ -701,6 +739,12 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { miniCrest { gatsbyImageData(width: 40, height: 40) } + crest { + publicId + } + objectives { + body + } profiles { data { id @@ -756,6 +800,30 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { const allFeatures = useMemo(() => [...grouped.comingSoon, ...grouped.beta], [grouped.beta, grouped.comingSoon]) const total = allFeatures.length + // Teams shown in the quarter grid — mirrors the old /wip query filter (crested teams, no Hedgehogs). + const gridTeams: GridTeam[] = useMemo(() => { + const featureCountBySlug: Record = {} + allFeatures.forEach((feature) => { + const slug = + ROADMAP_TEAM_OVERRIDES[feature.flagKey] || + teamByFeatureSlug[feature.flagKey] || + teamByFeatureSlug[slugify(feature.name)] + if (slug) { + featureCountBySlug[slug] = (featureCountBySlug[slug] || 0) + 1 + } + }) + return allSqueakTeam.nodes + .filter((node) => node.name !== 'Hedgehogs' && node.crest?.publicId) + .map((node) => ({ + slug: node.slug, + name: node.name, + miniCrest: node.miniCrest, + objectivesBody: node.objectives?.body || undefined, + featureCount: featureCountBySlug[node.slug] || 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)) + }, [allSqueakTeam, allFeatures, teamByFeatureSlug]) + useEffect(() => { if (!roadmapRootRef.current) { return @@ -779,18 +847,24 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { unassigned += 1 } }) - const teams = Object.entries(counts) - .map(([slug, count]) => ({ - value: slug, - label: `${teamInfoBySlug[slug]?.name || slug} (${count})`, - })) - .sort((a, b) => a.label.localeCompare(b.label)) + // Every grid team is selectable (zero features allowed) so the Select can filter the + // quarter grid too; feature-owning slugs without a grid card are appended. + const gridSlugs = new Set(gridTeams.map((team) => team.slug)) + const teams = [ + ...gridTeams.map((team) => ({ value: team.slug, label: `${team.name} (${counts[team.slug] || 0})` })), + ...Object.entries(counts) + .filter(([slug]) => !gridSlugs.has(slug)) + .map(([slug, count]) => ({ + value: slug, + label: `${teamInfoBySlug[slug]?.name || slug} (${count})`, + })), + ].sort((a, b) => a.label.localeCompare(b.label)) const options = [{ value: 'all', label: 'All teams' }, ...teams] if (unassigned) { options.push({ value: 'unassigned', label: `Unassigned (${unassigned})` }) } return options - }, [allFeatures, teamByFeatureSlug, teamInfoBySlug]) + }, [allFeatures, gridTeams, teamByFeatureSlug, teamInfoBySlug]) const requestedFlagKey = useMemo( () => new URLSearchParams(location.search).get('feature') || undefined, @@ -801,7 +875,24 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { : undefined const activeFlagKey = selectedFlagKey ?? requestedFlagKey const activeFeature = activeFlagKey ? allFeatures.find((feature) => feature.flagKey === activeFlagKey) : undefined - const drawerOpen = pitchOpen || !!activeFeature + + const requestedTeamParam = useMemo( + () => new URLSearchParams(location.search).get('team') || undefined, + [location.search] + ) + // Accept a slug, or (for legacy inbound links from product pages) a team name. + const requestedTeamSlug = useMemo(() => { + if (!requestedTeamParam) { + return undefined + } + if (gridTeams.some((team) => team.slug === requestedTeamParam)) { + return requestedTeamParam + } + return gridTeams.find((team) => team.name.toLowerCase() === requestedTeamParam.toLowerCase())?.slug + }, [gridTeams, requestedTeamParam]) + const activeTeamSlug = selectedTeamSlug ?? requestedTeamSlug + const activeTeam = activeTeamSlug ? gridTeams.find((team) => team.slug === activeTeamSlug) : undefined + const drawerOpen = pitchOpen || !!activeFeature || !!activeTeam useEffect(() => { if (selectedFlagKey && requestedFlagKey === selectedFlagKey) { @@ -809,6 +900,35 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { } }, [requestedFlagKey, selectedFlagKey]) + useEffect(() => { + if (selectedTeamSlug && requestedTeamSlug === selectedTeamSlug) { + setSelectedTeamSlug(undefined) + } + }, [requestedTeamSlug, selectedTeamSlug]) + + // The URL carries at most one drawer param; `feature` wins when both arrive. + useEffect(() => { + if (requestedFlagKey && requestedTeamParam) { + navigate(roadmapUrl(location.search, { feature: requestedFlagKey }), { replace: true }) + } + }, [location.search, requestedFlagKey, requestedTeamParam]) + + // Normalize legacy name-form ?team= links to the slug and filter the board to that team; + // strip values that match no team so the board stays usable. + useEffect(() => { + if (!requestedTeamParam || requestedFlagKey) { + return + } + if (!requestedTeamSlug) { + navigate(roadmapUrl(location.search), { replace: true }) + return + } + if (requestedTeamSlug !== requestedTeamParam) { + setTeamFilter(requestedTeamSlug) + navigate(roadmapUrl(location.search, { team: requestedTeamSlug }), { replace: true }) + } + }, [location.search, requestedFlagKey, requestedTeamParam, requestedTeamSlug]) + useEffect(() => { if (requestedFlagKey || !location.hash || loading) { return @@ -820,7 +940,7 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { return } if (allFeatures.some((feature) => feature.flagKey === legacyFlagKey)) { - navigate(roadmapUrl(location.search, legacyFlagKey), { replace: true }) + navigate(roadmapUrl(location.search, { feature: legacyFlagKey }), { replace: true }) } }, [allFeatures, loading, location.hash, location.search, requestedFlagKey]) @@ -842,6 +962,7 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { } setPitchOpen(false) setSelectedFlagKey(undefined) + setSelectedTeamSlug(undefined) navigate(roadmapUrl(location.search), { replace: true }) } @@ -857,12 +978,7 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { return new Set(ranked.map((feature) => feature.flagKey)) }, [allFeatures]) - if (loading && total === 0) { - return

Loading what's new…

- } - if (total === 0) { - return null - } + const { quarter, year } = getCurrentQuarter() const now = Date.now() const isNew = (feature: EarlyAccessFeature): boolean => @@ -904,23 +1020,54 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { ) const filteredTotal = STAGES.reduce((count, { stage }) => count + filteredByStage[stage].length, 0) + + const filteredGridTeams = gridTeams.filter((team) => { + const value = query.trim().toLowerCase() + const matchesQuery = !value || team.name.toLowerCase().includes(value) + const matchesTeamSelect = + teamFilter === 'all' ? true : teamFilter === 'unassigned' ? false : team.slug === teamFilter + return matchesQuery && matchesTeamSelect + }) + const filterGridTeam = gridTeams.find((team) => team.slug === teamFilter) + const openFeature = (feature: EarlyAccessFeature) => { setSelectedFlagKey(feature.flagKey) + setSelectedTeamSlug(undefined) + setPitchOpen(false) + navigate(roadmapUrl(location.search, { feature: feature.flagKey }), { replace: true }) + } + const openTeam = (slug: string) => { + setSelectedTeamSlug(slug) + setSelectedFlagKey(undefined) setPitchOpen(false) - navigate(roadmapUrl(location.search, feature.flagKey), { replace: true }) + navigate(roadmapUrl(location.search, { team: slug }), { replace: true }) } const openPitch = () => { setPitchOpen(true) setSelectedFlagKey(undefined) + setSelectedTeamSlug(undefined) navigate(roadmapUrl(location.search), { replace: true }) } const closeDrawer = () => { setPitchOpen(false) setSelectedFlagKey(undefined) + setSelectedTeamSlug(undefined) navigate(roadmapUrl(location.search), { replace: true }) } - const activeTeamSlug = activeFeature ? teamForFeature(activeFeature) : undefined - const drawerTitle = pitchOpen ? 'Pitch a roadmap idea' : activeFeature?.name ?? 'Roadmap feature' + const activeFeatureTeamSlug = activeFeature ? teamForFeature(activeFeature) : undefined + const stageOrder: Record = { beta: 0, alpha: 1, concept: 2 } + const activeTeamFeatures = activeTeam + ? allFeatures + .filter((feature) => teamForFeature(feature) === activeTeam.slug) + .sort( + (a, b) => + (stageOrder[a.stage as BoardStage] ?? 3) - (stageOrder[b.stage as BoardStage] ?? 3) || + a.name.localeCompare(b.name) + ) + : [] + const drawerTitle = pitchOpen + ? 'Pitch a roadmap idea' + : activeFeature?.name ?? (activeTeam ? `${activeTeam.name} Team` : 'Roadmap feature') return (
@@ -949,30 +1096,58 @@ export default function EarlyAccessFeaturesSection(): JSX.Element | null { className="!h-10 w-full max-w-full overflow-hidden !rounded-[8px] px-3 [&>span]:min-w-0 [&>span]:truncate" />
+ {filterGridTeam && ( + openTeam(filterGridTeam.slug)} + > + View team goals + + )} {filteredTotal} of {total} features
-
-
- {STAGES.map((definition) => ( - - ))} + {/* Board area — the team grid below stays useful even while features load or are unavailable. */} + {total === 0 ? ( +

+ {loading ? "Loading what's new…" : 'Roadmap features are unavailable right now.'} +

+ ) : ( +
+
+ {STAGES.map((definition) => ( + + ))} +
-
+ )} + + team.slug === activeFeatureTeamSlug) + ? openTeam + : undefined + } + /> + ) : activeTeam ? ( + ( + openFeature(feature)} + showStage + /> + )} /> ) : null} diff --git a/src/components/Roadmap/README.md b/src/components/Roadmap/README.md index 42d83dc75653..c86f14c5c821 100644 --- a/src/components/Roadmap/README.md +++ b/src/components/Roadmap/README.md @@ -1,13 +1,14 @@ # Roadmap board -`EarlyAccessFeaturesSection` powers `/roadmap`. It presents the same early-access data and enrollment flows as the PostHog app in a compact, changelog-style board. +`EarlyAccessFeaturesSection` powers `/roadmap`. It presents the same early-access data and enrollment flows as the PostHog app in a compact, changelog-style board, followed by a quarter grid showing what every team is working on (the former `/wip` page, which now redirects to `/roadmap#teams`; the page shell scrolls the Editor viewport to the grid when it sees that hash, since native anchor navigation doesn't reach into the app's own scroll container). ## Data ownership - `useEarlyAccessFeatures` owns the feature data. It starts with Gatsby's build-time `EarlyAccessFeature` nodes, then revalidates with PostHog JS in the browser. - The hook supplies the feature stage, title, description, documentation URL, flag key, creation date, waitlist count, and waitlist survey payload. - `useFeatureOwnership` supplies the shared feature-to-small-team map. `roadmapTeamOverrides.ts` is the curated flag-key override and takes precedence over that map. -- `allSqueakTeam` supplies display names, mini crests, and member profiles. Features without a match remain visible and can be filtered as `Unassigned`. +- `allSqueakTeam` supplies display names, mini crests, crest presence, member profiles, and quarterly objectives. Features without a match remain visible and can be filtered as `Unassigned`. +- Objectives are freeform MDX at `contents/teams/{slug}/objectives.mdx`, resolved onto `SqueakTeam.objectives` by the schema customization in `gatsby/createSchemaCustomization.ts`. `TeamObjectives.tsx` owns the current-quarter parsing: `getCurrentQuarter` derives the quarter from today's date, and `QuarterObjectives` renders the MDX then hides everything outside the heading matching `Q{quarter} {year}`, collapsing "recap" sections into a native `
`. Do not duplicate this data in the component or add hard-coded feature cards. Update the source early-access feature, shared ownership data, or the roadmap override map instead. @@ -28,9 +29,13 @@ Cards intentionally contain only: The Concept lane ends with the dashed `Your idea here` card. It opens the shared overlay drawer containing the roadmap pitch form; the form is not duplicated per lane. The Beta lane ends with a matching dashed `What's just shipped?` card that links to `/changelog`, pointing onward from the last pre-release stage. +## Team quarter grid + +Below the board, `TeamQuarterSection` renders one compact card per team: mini crest (with an icon fallback), team name, and a muted count of that team's roadmap features when it has any. The team set mirrors the old `/wip` query — crested teams, excluding Hedgehogs — so teams without an objectives file still get a card; their drawer shows the "hasn't set goals yet" state. Cards are alphabetical and are buttons that open the shared drawer, not links. The grid uses container-query columns and never scrolls itself; the Editor window keeps the page's only vertical scrollbar. When filters produce zero cards the grid shows a single muted "No teams match" line (unlike lanes, an empty grid under a heading reads as broken). The board area behaves the same way in reverse: while features load or are unavailable, a single muted line appears in its place and the team grid stays fully usable. + ## Filtering -One search query is applied before grouping into lanes. It matches the title, full description, flag key, and owning team name. Search is cleared directly from the search field. The team selector is applied at the same time, includes an `Unassigned` option when necessary, and resets through its `All teams` option. Neither control adds a redundant filter chip below the toolbar. The toolbar uses the same primary surface as each lane header, and its rounded search and team controls share one height. Counts always reflect both filters. Empty lanes stay visually empty rather than adding a redundant no-results message; their zero count already communicates the state. +One search query is applied before grouping into lanes. It matches the title, full description, flag key, and owning team name — and, for the team grid, the team name. Search is cleared directly from the search field. The team selector is applied at the same time and filters both surfaces: it lists every grid team (with its feature count, zero allowed) plus any feature-owning slugs without a grid card, includes an `Unassigned` option when necessary (which matches no grid card), and resets through its `All teams` option. When the selected team has a grid card, a `View team goals` button appears beside the selector and opens that team's drawer; it carries `data-roadmap-item` so the outside-click closer ignores it. The page header (in `src/pages/roadmap/index.js`) offers a matching jump control that scrolls the Editor viewport to the grid's `#teams` heading, respecting reduced motion. Neither control adds a redundant filter chip below the toolbar. The toolbar uses the same primary surface as each lane header, and its rounded search and team controls share one height. Counts always reflect both filters. Empty lanes stay visually empty rather than adding a redundant no-results message; their zero count already communicates the state. Featured ordering is preserved within each lane: new cards lead, and joinable alpha/concept items lead their stage. There are no stage or sorting controls because the lanes themselves communicate stage. @@ -56,9 +61,9 @@ The drawer animates only when it opens from a closed state or closes. Switching The drawer is non-modal. Clicking another feature or idea card replaces its content without closing the shell or replaying the animation. Clicking elsewhere inside the roadmap's Editor surface closes it; clicks within the drawer itself do not. Escape remains disabled, and the standard `OSButton` window close control always dismisses it. This behavior uses one Editor-scoped click listener plus `data-roadmap-item` and `data-roadmap-drawer` markers—do not add a scrim or restore modal outside-click handling. -The drawer contains the full description, documentation link, linked small team, complete avatar roster with tooltips, and the stage-specific action. +For a feature, the drawer contains the full description, documentation link, linked small team, complete avatar roster with tooltips, and the stage-specific action. For a team (`TeamPanel`), it contains the team's current-quarter objectives and its roadmap features. The two hop symmetrically without a back stack: feature rows inside the team panel swap the drawer to that feature (showing a stage chip instead of the redundant team line), and the feature panel's "View team goals" button swaps back to the team — offered only when the owning team has a grid card. -The canonical URL is `/roadmap?feature=`. Opening a card adds only `feature` with a history replacement, and closing removes only that parameter the same way. This keeps visible URLs shareable without turning browser Back into another drawer-dismiss control. Invalid values leave the board usable and are removed safely after feature data loads. +The canonical URLs are `/roadmap?feature=` and `/roadmap?team=`, and the URL carries at most one drawer param — setting one clears the other, and `feature` wins if both arrive. Opening a card adds its param with a history replacement, and closing removes it the same way. This keeps visible URLs shareable without turning browser Back into another drawer-dismiss control. Invalid values leave the board usable and are removed safely after feature data loads. Legacy inbound links pass a team name (`/roadmap?team=Session%20Replay`, from product pages): a case-insensitive name match normalizes the URL to the slug, sets the team filter to that team, and opens its drawer; unresolvable values are stripped. Legacy `/roadmap#` URLs are supported. A valid hash is normalized to the canonical query URL with a history replacement. diff --git a/src/components/Roadmap/TeamObjectives.tsx b/src/components/Roadmap/TeamObjectives.tsx new file mode 100644 index 000000000000..d93289bd29cb --- /dev/null +++ b/src/components/Roadmap/TeamObjectives.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useRef } from 'react' +import { MDXProvider } from '@mdx-js/react' +import { MDXRenderer } from 'gatsby-plugin-mdx' +import TeamMemberComponent, { FutureTeamMember } from 'components/TeamMember' +import SmallTeam from 'components/SmallTeam' + +const TypedMDXProvider = MDXProvider as React.ComponentType<{ + components: Record> // eslint-disable-line @typescript-eslint/no-explicit-any + children: React.ReactNode +}> + +export function getCurrentQuarter(): { quarter: number; year: number } { + const now = new Date() + const quarter = Math.floor(now.getMonth() / 3) + 1 + return { quarter, year: now.getFullYear() } +} + +/** + * Renders a team's objectives.mdx body scoped to the current quarter. The MDX files are + * freeform prose with quarter-labeled headings (e.g. "Q3 2026 Objectives", "Q2 2026 Recap"), + * so after render this hides everything outside the section under the heading matching + * `Q{quarter} {year}` and collapses any "recap" section into a native
. + */ +export function QuarterObjectives({ + body, + quarter, + year, +}: { + body: string + quarter: number + year: number +}): JSX.Element { + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + const container = ref.current + const headings = container.querySelectorAll('h1, h2, h3') + const qRegex = new RegExp(`Q${quarter}\\s+${year}`, 'i') + const recapRegex = /recap/i + + let currentQHeading: Element | null = null + let currentQLevel = 0 + + for (const h of Array.from(headings)) { + const text = h.textContent || '' + if (qRegex.test(text) && !recapRegex.test(text)) { + currentQHeading = h + currentQLevel = parseInt(h.tagName[1]) + break + } + } + + if (!currentQHeading) return + + // Hide the quarter heading itself (the surrounding UI already shows the quarter) + const qHeadEl = currentQHeading as HTMLElement + qHeadEl.style.display = 'none' + + // Hide everything before the current quarter heading + let el = container.firstElementChild + while (el && el !== currentQHeading) { + const next = el.nextElementSibling + const elHtml = el as HTMLElement + elHtml.style.display = 'none' + el = next + } + + // Process elements after the current quarter section + let sibling = currentQHeading.nextElementSibling + let pastCurrentSection = false + + while (sibling) { + const next = sibling.nextElementSibling + + if (sibling.matches('h1, h2, h3') && parseInt(sibling.tagName[1]) <= currentQLevel) { + pastCurrentSection = true + const text = sibling.textContent || '' + + if (recapRegex.test(text)) { + // Wrap recap section in a collapsible
+ const details = document.createElement('details') + details.className = 'my-3 border border-primary rounded' + const summary = document.createElement('summary') + summary.className = + 'cursor-pointer px-3 py-2 text-sm font-semibold text-secondary hover:text-primary list-none flex items-center gap-1.5' + summary.textContent = text + details.appendChild(summary) + + const wrapper = document.createElement('div') + wrapper.className = 'px-3 pb-3' + + const sibHtml = sibling as HTMLElement + sibHtml.style.display = 'none' + + let recapEl = next + while (recapEl) { + if (recapEl.matches('h1, h2, h3') && parseInt(recapEl.tagName[1]) <= currentQLevel) { + break + } + const recapNext = recapEl.nextElementSibling + wrapper.appendChild(recapEl) + recapEl = recapNext + } + + details.appendChild(wrapper) + sibling.parentNode?.insertBefore(details, sibling.nextSibling) + sibling = recapEl + continue + } + } + + if (pastCurrentSection) { + const pastSibHtml = sibling as HTMLElement + pastSibHtml.style.display = 'none' + } + + sibling = next + } + }, [body, quarter, year]) + + return ( +
+ + {body} + +
+ ) +} diff --git a/src/components/Roadmap/TeamPanel.tsx b/src/components/Roadmap/TeamPanel.tsx new file mode 100644 index 000000000000..9eeaba45155f --- /dev/null +++ b/src/components/Roadmap/TeamPanel.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { GatsbyImage, getImage } from 'gatsby-plugin-image' +import Link from 'components/Link' +import ScrollArea from 'components/RadixUI/ScrollArea' +import { EarlyAccessFeature } from 'hooks/useEarlyAccessFeatures' +import { QuarterObjectives } from './TeamObjectives' + +export interface TeamPanelTeam { + slug: string + name: string + miniCrest?: Parameters[0] +} + +/** + * Drawer content for a team: its current-quarter objectives plus its roadmap features. + * Feature rows are rendered by the caller (via renderFeature) so this file doesn't import + * the board's card components back out of the orchestrator. + */ +const TeamPanel = ({ + team, + objectivesBody, + quarter, + year, + features, + renderFeature, +}: { + team: TeamPanelTeam + objectivesBody?: string + quarter: number + year: number + features: EarlyAccessFeature[] + renderFeature: (feature: EarlyAccessFeature) => React.ReactNode +}): JSX.Element => { + const crest = team.miniCrest ? getImage(team.miniCrest) : undefined + + return ( +
+
+
+ {crest && ( + + )} +
+

{team.name} Team

+ + Visit team page + +
+
+
+ + +
+
+

+ This quarter (Q{quarter} {year}) +

+ {objectivesBody ? ( + + ) : ( +

This team hasn't set goals yet.

+ )} +
+ {features.length > 0 && ( +
+

On the roadmap

+
    + {features.map((feature) => ( +
  • + {renderFeature(feature)} +
  • + ))} +
+
+ )} +
+
+
+ ) +} + +export default TeamPanel diff --git a/src/components/Roadmap/TeamQuarterSection.tsx b/src/components/Roadmap/TeamQuarterSection.tsx new file mode 100644 index 000000000000..0644e32474c9 --- /dev/null +++ b/src/components/Roadmap/TeamQuarterSection.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { GatsbyImage, getImage } from 'gatsby-plugin-image' +import { IconPeople } from '@posthog/icons' +import Link from 'components/Link' + +export interface QuarterGridTeam { + slug: string + name: string + miniCrest?: Parameters[0] + featureCount: number +} + +const TeamCard = ({ + team, + active, + onClick, +}: { + team: QuarterGridTeam + active: boolean + onClick: () => void +}): JSX.Element => { + const crest = team.miniCrest ? getImage(team.miniCrest) : undefined + + return ( + + ) +} + +/** + * Compact grid of team cards below the feature board. Cards open the shared drawer with the + * team's quarterly objectives; the grid itself never scrolls — the Editor window owns the + * page's only vertical scrollbar. + */ +const TeamQuarterSection = ({ + teams, + totalTeams, + quarter, + year, + activeTeamSlug, + onTeamClick, +}: { + teams: QuarterGridTeam[] + totalTeams: number + quarter: number + year: number + activeTeamSlug?: string + onTeamClick: (slug: string) => void +}): JSX.Element => ( +
+
+
+

+ What teams are working on this quarter +

+ + {teams.length} of {totalTeams} teams + +
+

+ Every quarter, each team sets Q{quarter} {year} goals using our{' '} + + HOGS process + + . Click a team to see its current goals and roadmap features. +

+
+ {teams.length === 0 ? ( +

No teams match.

+ ) : ( +
    + {teams.map((team) => ( +
  • + onTeamClick(team.slug)} + /> +
  • + ))} +
+ )} +
+) + +export default TeamQuarterSection diff --git a/src/components/Roadmap/index.tsx b/src/components/Roadmap/index.tsx index bef80cceb89b..4c1aeb2866e9 100644 --- a/src/components/Roadmap/index.tsx +++ b/src/components/Roadmap/index.tsx @@ -136,7 +136,8 @@ export const Feature = ({ id, title, teams, description, likeCount, onLike, onUp useEffect(() => { const params = new URLSearchParams(search) if (params.get('id')) { - navigate(`/wip${search}`) + // Legacy Squeak-roadmap deep links; the merged /roadmap page doesn't consume `id`. + navigate('/roadmap') } }, []) diff --git a/src/components/TaskBarMenu/menuData.tsx b/src/components/TaskBarMenu/menuData.tsx index 85876d007269..f3fa2d4c1b9d 100644 --- a/src/components/TaskBarMenu/menuData.tsx +++ b/src/components/TaskBarMenu/menuData.tsx @@ -424,12 +424,6 @@ export function useMenuData(): MenuType[] { link: '/roadmap', icon: getMenuIcon(companyMenu.children, '/roadmap', 'IconMap', 'orange'), }, - { - type: 'item', - label: 'WIP', - link: '/wip', - icon: getMenuIcon(companyMenu.children, '/wip', 'IconWrench', 'green'), - }, { type: 'item', label: 'Changelog', diff --git a/src/pages/research.tsx b/src/pages/research.tsx index 8dbc3afb26de..04e8b583a17c 100644 --- a/src/pages/research.tsx +++ b/src/pages/research.tsx @@ -961,7 +961,7 @@ function CTASection() { feature previews are currently available to try in the app.

- + See what we're working on { + const scrollToTeamGoals = () => { + const target = document.getElementById('teams') + if (!target) { + return + } + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + target.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }) + } + + // /wip redirects to /roadmap#teams; the Editor scrolls in its own viewport, so native + // anchor navigation doesn't reach the heading — scroll it into view after first paint. + useEffect(() => { + if (window.location.hash !== '#teams') { + return + } + const timeout = window.setTimeout(scrollToTeamGoals, 100) + return () => window.clearTimeout(timeout) + }, []) + return ( <> { maxWidth="100%" bookmark={{ title: 'Roadmap', - description: "See what we're building next", + description: "See what we're building next and what every team is working on this quarter", }} >
{ {' '} is ready to try, and then it reaches general availability.

+
+ } + onClick={scrollToTeamGoals} + > + Jump to this quarter's team goals + +
> // eslint-disable-line @typescript-eslint/no-explicit-any - children: React.ReactNode -}> - -function getCurrentQuarter() { - const now = new Date() - const quarter = Math.floor(now.getMonth() / 3) + 1 - return { quarter, year: now.getFullYear() } -} - -function stripQuarterPrefix(excerpt: string, quarter: number, year: number): string { - const quarterPrefixRegex = new RegExp( - `^\\s*(?:#+\\s*)?Q${quarter}\\s+${year}\\s*(?:objectives?|objective\\s*\\d*|goals?)?\\s*[:\\-–]?\\s*`, - 'i' - ) - return excerpt.replace(quarterPrefixRegex, '').trim() -} - -interface TeamNode { - id: string - name: string - slug: string - crest?: { - data?: { - attributes?: { - url?: string - } - } - } - objectives?: { - body?: string - excerpt?: string - } | null -} - -function QuarterObjectives({ body, quarter, year }: { body: string; quarter: number; year: number }): JSX.Element { - const ref = useRef(null) - - useEffect(() => { - if (!ref.current) return - const container = ref.current - const headings = container.querySelectorAll('h1, h2, h3') - const qRegex = new RegExp(`Q${quarter}\\s+${year}`, 'i') - const recapRegex = /recap/i - - let currentQHeading: Element | null = null - let currentQLevel = 0 - - for (const h of Array.from(headings)) { - const text = h.textContent || '' - if (qRegex.test(text) && !recapRegex.test(text)) { - currentQHeading = h - currentQLevel = parseInt(h.tagName[1]) - break - } - } - - if (!currentQHeading) return - - // Hide the quarter heading itself (page title already shows the quarter) - const qHeadEl = currentQHeading as HTMLElement - qHeadEl.style.display = 'none' - - // Hide everything before the current quarter heading - let el = container.firstElementChild - while (el && el !== currentQHeading) { - const next = el.nextElementSibling - const elHtml = el as HTMLElement - elHtml.style.display = 'none' - el = next - } - - // Process elements after the current quarter section - let sibling = currentQHeading.nextElementSibling - let pastCurrentSection = false - - while (sibling) { - const next = sibling.nextElementSibling - - if (sibling.matches('h1, h2, h3') && parseInt(sibling.tagName[1]) <= currentQLevel) { - pastCurrentSection = true - const text = sibling.textContent || '' - - if (recapRegex.test(text)) { - // Wrap recap section in a collapsible
- const details = document.createElement('details') - details.className = 'my-3 border border-light dark:border-dark rounded' - const summary = document.createElement('summary') - summary.className = - 'cursor-pointer px-3 py-2 text-sm font-semibold text-primary/60 hover:text-primary/90 list-none flex items-center gap-1.5' - summary.textContent = text - details.appendChild(summary) - - const wrapper = document.createElement('div') - wrapper.className = 'px-3 pb-3' - - const sibHtml = sibling as HTMLElement - sibHtml.style.display = 'none' - - let recapEl = next - while (recapEl) { - if (recapEl.matches('h1, h2, h3') && parseInt(recapEl.tagName[1]) <= currentQLevel) { - break - } - const recapNext = recapEl.nextElementSibling - wrapper.appendChild(recapEl) - recapEl = recapNext - } - - details.appendChild(wrapper) - sibling.parentNode?.insertBefore(details, sibling.nextSibling) - sibling = recapEl - continue - } - } - - if (pastCurrentSection) { - const pastSibHtml = sibling as HTMLElement - pastSibHtml.style.display = 'none' - } - - sibling = next - } - }, [body, quarter, year]) - - return ( -
- - {body} - -
- ) -} - -export default function WhatWereWorkingOn(): JSX.Element { - const [teamFilter, setTeamFilter] = useState('all') - const [expandedObjectives, setExpandedObjectives] = useState>({}) - const { quarter, year } = getCurrentQuarter() - - const { allSqueakTeam } = useStaticQuery<{ allSqueakTeam: { nodes: TeamNode[] } }>(graphql` - { - allSqueakTeam(filter: { name: { ne: "Hedgehogs" }, crest: { publicId: { ne: null } } }) { - nodes { - id - objectives { - body - excerpt(pruneLength: 250) - } - name - slug - crest { - data { - attributes { - url - } - } - } - } - } - } - `) - - const teams = useMemo( - () => [...allSqueakTeam.nodes].sort((a, b) => a.name.localeCompare(b.name)), - [allSqueakTeam.nodes] - ) - - const filteredTeams = useMemo(() => { - if (teamFilter === 'all') return teams - return teams.filter((t) => t.slug === teamFilter) - }, [teams, teamFilter]) - - const expandableTeamSlugs = useMemo( - () => - filteredTeams.filter((team) => team.objectives?.body && team.objectives?.excerpt).map((team) => team.slug), - [filteredTeams] - ) - - const allExpandableTeamsExpanded = - expandableTeamSlugs.length > 0 && expandableTeamSlugs.every((slug) => expandedObjectives[slug]) - - const toggleObjective = (slug: string) => { - setExpandedObjectives((prev) => ({ - ...prev, - [slug]: !prev[slug], - })) - } - - const toggleAllObjectives = () => { - setExpandedObjectives((prev) => { - const next = { ...prev } - - if (allExpandableTeamsExpanded) { - expandableTeamSlugs.forEach((slug) => { - delete next[slug] - }) - } else { - expandableTeamSlugs.forEach((slug) => { - next[slug] = true - }) - } - - return next - }) - } - - const tableColumns = [ - { name: 'Team', width: '250px', align: 'left' as const }, - { name: `Objectives`, width: 'minmax(500px, 2fr)', align: 'left' as const }, - ] - - const tableRows = useMemo( - () => - filteredTeams.map((team) => { - const crestUrl = team.crest?.data?.attributes?.url - const body = team.objectives?.body - const rawExcerpt = team.objectives?.excerpt || '' - const excerpt = stripQuarterPrefix(rawExcerpt, quarter, year) - const isExpanded = !!expandedObjectives[team.slug] - const canExpand = !!body && !!excerpt - - return { - key: team.id, - cells: [ - { - content: ( - - {crestUrl && ( - - )} - {team.name} - - ), - }, - { - content: body ? ( - canExpand && !isExpanded ? ( -
-

{excerpt}

- -
- ) : ( -
- - {canExpand && ( - - )} -
- ) - ) : ( -

This team hasn't set goals yet.

- ), - className: '!py-4', - }, - ], - } - }), - [filteredTeams, expandedObjectives, quarter, year] - ) - - return ( - <> - - -
-
-
-

Work in progress

-
-
- -