Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contents/handbook/company/goal-setting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion contents/handbook/engineering/posthog-com/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The [changelog](/changelog) publishing process is run by the <SmallTeam slug="wi

Changelog entries can be created in two ways:

1. Change the status of an existing [roadmap](/roadmap) or [WIP](/wip) entry to "Complete"
1. Change the status of an existing [roadmap](/roadmap) entry to "Complete"
2. Create a new changelog entry on the [changelog](/changelog) page.

Both options are available when you're signed into posthog.com as a moderator.
Expand Down
5 changes: 0 additions & 5 deletions src/components/Korean/KoreanTaskBarMenu/menuData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,11 +532,6 @@ export function useMenuData(): MenuType[] {
label: 'Roadmap',
link: '/roadmap',
},
{
type: 'item',
label: 'WIP',
link: '/wip',
},
{
type: 'item',
label: 'Changelog',
Expand Down
286 changes: 242 additions & 44 deletions src/components/Roadmap/EarlyAccessFeaturesSection.tsx

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions src/components/Roadmap/README.md
Original file line number Diff line number Diff line change
@@ -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 `<details>`.

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.

Expand All @@ -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.

Expand All @@ -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=<flagKey>`. 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=<flagKey>` and `/roadmap?team=<slug>`, 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#<flagKey>` URLs are supported. A valid hash is normalized to the canonical query URL with a history replacement.

Expand Down
135 changes: 135 additions & 0 deletions src/components/Roadmap/TeamObjectives.tsx
Original file line number Diff line number Diff line change
@@ -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<string, React.ComponentType<any>> // 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 <details>.
*/
export function QuarterObjectives({
body,
quarter,
year,
}: {
body: string
quarter: number
year: number
}): JSX.Element {
const ref = useRef<HTMLDivElement>(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 <details>
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 (
<div ref={ref} className="prose prose-sm max-w-none dark:prose-invert">
<TypedMDXProvider
components={{
TeamMember: TeamMemberComponent,
FutureTeamMember,
SmallTeam,
}}
>
<MDXRenderer>{body}</MDXRenderer>
</TypedMDXProvider>
</div>
)
}
86 changes: 86 additions & 0 deletions src/components/Roadmap/TeamPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof getImage>[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 (
<div data-scheme="primary" className="flex h-full min-h-0 flex-col bg-primary">
<header className="shrink-0 border-b border-primary px-4 py-4 pr-14">
<div className="flex items-center gap-3">
{crest && (
<GatsbyImage image={crest} alt={`${team.name} team mini crest`} className="size-10 shrink-0" />
)}
<div className="min-w-0">
<h2 className="m-0 text-2xl leading-tight">{team.name} Team</h2>
<Link
to={`/teams/${team.slug}`}
state={{ newWindow: true }}
className="text-sm font-semibold text-red dark:text-yellow"
>
Visit team page
</Link>
</div>
</div>
</header>

<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-6 p-4">
<div>
<h3 className="mb-2 mt-0 text-sm">
This quarter (Q{quarter} {year})
</h3>
{objectivesBody ? (
<QuarterObjectives body={objectivesBody} quarter={quarter} year={year} />
) : (
<p className="m-0 text-sm italic text-secondary">This team hasn't set goals yet.</p>
)}
</div>
{features.length > 0 && (
<div>
<h3 className="mb-2 mt-0 text-sm">On the roadmap</h3>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{features.map((feature) => (
<li key={feature.flagKey} className="m-0 list-none p-0">
{renderFeature(feature)}
</li>
))}
</ul>
</div>
)}
</div>
</ScrollArea>
</div>
)
}

export default TeamPanel
Loading
Loading