Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
0f0e289
chore(deps): update support-widget to v0.10.0 (neutral bubble shadow)
uz1mani Jun 25, 2026
0c3d079
fix(middleware): exclude sw.js and workbox scripts from auth matcher
uz1mani Jul 3, 2026
dae7504
fix(marketing): correct Hobby plan pageview limit on features page
uz1mani Jul 3, 2026
89d0eb4
fix(marketing): colour compare-table cells by sentiment, not raw boolean
uz1mani Jul 3, 2026
5bba788
fix(marketing): sentence-case the integrations section headers
uz1mani Jul 3, 2026
688ef03
fix(dashboard): add missing Roles & Permissions breadcrumb label
uz1mani Jul 3, 2026
dbff2a3
chore(ui): remove dead line-chart, funnel-chart and avatar components
uz1mani Jul 3, 2026
9031101
fix(privacy): stop leaking visited domains to Google via favicon lookups
uz1mani Jul 3, 2026
3d4a632
fix(settings): surface bot-spam session load failures instead of fake…
uz1mani Jul 3, 2026
70fe001
fix(settings): explicit notice when encrypted profile details are una…
uz1mani Jul 3, 2026
143a205
fix(ui): floating layers cast a shadow per the Facet floating-layer rule
uz1mani Jul 3, 2026
89eec37
fix(settings): keep the settings header and left nav visible while sc…
uz1mani Jul 3, 2026
1bdc7e5
fix(changelog): collapse older releases and drop the duplicate heading
uz1mani Jul 3, 2026
3c4b2dc
fix(integrations): compact overview grid and legible brand glyphs
uz1mani Jul 3, 2026
6b97ec2
fix(settings): one plan-name source of truth for retention and billin…
uz1mani Jul 3, 2026
1d213d7
fix(settings): stop rendering raw UUIDs as member names
uz1mani Jul 3, 2026
b183070
fix(settings): humanize audit-log actions and payloads
uz1mani Jul 3, 2026
3be0564
fix(auth): error state when the callback is missing its code
uz1mani Jul 3, 2026
6c1e6f6
fix(setup): explain the missing snippet when no site is attached
uz1mani Jul 3, 2026
d4a1b0a
fix(ui): recolour off-palette admin and setup chrome to brand orange
uz1mani Jul 3, 2026
f2b649c
fix(charts): thin x-axis ticks to the chart width
uz1mani Jul 3, 2026
77be7fc
fix(dashboard): neutralise KPI deltas on tiny comparison windows, exp…
uz1mani Jul 3, 2026
a3c1105
fix(billing): preselect the org's current billing interval on /switch
uz1mani Jul 3, 2026
c2c5943
feat(billing): usage progress bar for pageview headroom
uz1mani Jul 3, 2026
676a8ce
fix(dashboard): page headers on the CDN and Search not-connected states
uz1mani Jul 3, 2026
225e853
refactor(plans): formatPlanName lives in lib/plans with the other pla…
uz1mani Jul 3, 2026
fcbaa73
fix(copy): align labels with reality across installation, join, funne…
uz1mani Jul 3, 2026
e9faec4
refactor(motion): use the shared EASE_APPLE constant in the roles tab
uz1mani Jul 3, 2026
3ad2707
fix(ui): solid slider thumb with a padded touch target
uz1mani Jul 3, 2026
a174973
fix(admin): consistent em-dash empties, orange active tab, card hover…
uz1mani Jul 3, 2026
0e8c87c
fix(privacy): let the CDN cache the favicon proxy
uz1mani Jul 3, 2026
70ea0fe
fix(changelog): collapse each older release to its heading
uz1mani Jul 3, 2026
b58552e
chore: drop the superseded lib/utils/plan.ts
uz1mani Jul 3, 2026
d9f0161
fix(settings): pin the sticky nav to the header's actual height
uz1mani Jul 3, 2026
25f0122
fix(dashboard): move scroller top padding into the content flow
uz1mani Jul 3, 2026
59b3a5f
chore(deps): facet 0.2.2 — captcha verifying border draw
uz1mani Jul 3, 2026
d18c28d
fix(dashboard): filter pills match the toolbar height; filter overlay…
uz1mani Jul 3, 2026
ca9aa4b
refactor(dashboard): extract ValuePicker from the filter modal
uz1mani Jul 4, 2026
a098431
feat(dashboard): filter builder draft state machine
uz1mani Jul 4, 2026
b5f4c38
feat(dashboard): anchored filter popover shell
uz1mani Jul 4, 2026
102100f
feat(dashboard): filter dimension stage with search and keyboard nav
uz1mani Jul 4, 2026
1918fe9
feat(dashboard): filter value stage with operator chip and apply flow
uz1mani Jul 4, 2026
afeb60f
feat(dashboard): single-surface filter builder replaces the filter modal
uz1mani Jul 4, 2026
90ea281
fix(dashboard): surface suggestion failures; keep chosen values visible
uz1mani Jul 4, 2026
ed694be
feat(dashboard): filter popover carries the panels' iconography
uz1mani Jul 4, 2026
8116a46
feat(dashboard): subtle motion across the filter popover
uz1mani Jul 4, 2026
e389870
fix(dashboard): kill the loading flicker when picking a filter dimension
uz1mani Jul 4, 2026
fbf3454
fix(dashboard): refocus the dimension search after navigating back
uz1mani Jul 4, 2026
8be2614
fix(data): keep stale data while revalidating and guard tiny-base deltas
uz1mani Jul 4, 2026
87c0f87
feat(ui): stepper and segmented controls
uz1mani Jul 4, 2026
6ef313f
feat(journeys): entry-point combobox and shared step header
uz1mani Jul 4, 2026
d8164b3
feat(journeys): single-row toolbar with steppers and combobox
uz1mani Jul 4, 2026
fa18010
feat(journeys): columns view with ambient ribbons and shareable lens
uz1mani Jul 4, 2026
59ab646
feat(journeys): declarative flow view on the shared lens
uz1mani Jul 4, 2026
63e6dc6
fix(journeys): first-load-only skeleton and in-place updates
uz1mani Jul 4, 2026
db5dadf
feat(funnels): shareable range and summary rows with real targets
uz1mani Jul 4, 2026
8936b0a
feat(funnels): real funnel detail page
uz1mani Jul 4, 2026
ae80c11
feat(funnels): funnel canvas
uz1mani Jul 4, 2026
9574323
feat(funnels): step drill-down with exits and breakdowns
uz1mani Jul 4, 2026
937bfcb
feat(funnels): conversion trend chart
uz1mani Jul 4, 2026
ed13238
feat(funnels): dashboard filters on funnel detail
uz1mani Jul 4, 2026
4055515
feat(funnels): rebuilt modal with suggestions and window control
uz1mani Jul 4, 2026
24de4e3
feat(flows): funnels and journeys cross-link
uz1mani Jul 4, 2026
6db45f8
chore(funnels,journeys): changelog for the rebuild
uz1mani Jul 4, 2026
4568e0a
fix(funnels): keep the dialog open when Escape closes a suggestion panel
uz1mani Jul 4, 2026
7dabf39
fix(funnels): cancel the pending panel close when refocusing a sugges…
uz1mani Jul 4, 2026
d8858b3
feat(journeys): country, device and referrer filters
uz1mani Jul 4, 2026
b6ae418
fix(funnels): tighten the funnel modal step editor
uz1mani Jul 4, 2026
8dc27c4
fix(funnels): keep the conversion window on one row
uz1mani Jul 4, 2026
e4d7b20
fix(ui): portal the Select dropdown and align it to the h-10 form sta…
uz1mani Jul 4, 2026
246e448
fix(funnels): one consistent control system across the modal
uz1mani Jul 4, 2026
0488a5d
fix(journeys): node-specific hover highlight and a tooltip that stays…
uz1mani Jul 4, 2026
af026c8
fix(journeys): cap the columns view at 10 rows per step
uz1mani Jul 4, 2026
ed1cd25
fix(funnels): drop the glitchy per-step toggles from the trend chart
uz1mani Jul 4, 2026
9c71d88
fix(data): keep stale integration data while revalidating and share t…
uz1mani Jul 4, 2026
269aee8
feat(behavior): kpi band, page lens, daily trend and scroll depth bars
uz1mani Jul 4, 2026
50121a3
feat(search): url-synced shell, sync status, kpi band and a dual-scal…
uz1mani Jul 4, 2026
a14f391
feat(search): queries, pages, countries, devices and opportunities views
uz1mani Jul 4, 2026
d670f52
fix(behavior): keep previous data across range changes and singular s…
uz1mani Jul 4, 2026
f9361a9
feat(cdn): cache-split charts, latency trend and ranked traffic distr…
uz1mani Jul 4, 2026
78238fc
chore(behavior,search,cdn): sweep and changelog
uz1mani Jul 4, 2026
cd37e61
fix(cdn): region-scoped datacenter country mapping, drop the dead req…
uz1mani Jul 4, 2026
f79ef47
fix(cdn): map Bunny's UK datacenter suffix to ISO GB
uz1mani Jul 4, 2026
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Improved

- **The Behavior page now tells you where the frustration is.** Click any row in "Frustration by page" to focus the whole page on it — the rage click and dead click tables instantly narrow to that page's elements, and the filter lives in the URL so you can share a link straight to "here's what's broken on /pricing". The old donut chart is replaced by a real day-by-day trend of rage and dead clicks, and scroll depth is now four clean bars instead of a radar chart. Stat changes versus the previous period only appear when there's enough history to compare against, so you'll no longer see a meaningless "-100%" on a brand-new site.
- **The Search page is now the full Search Console experience.** Alongside top queries and pages you can now browse clicks by country, by device, and — most usefully — an Opportunities view: queries ranking just off the first page where a better title or snippet could win real clicks, with the potential shown next to each. Expanding a query now also charts how its ranking position moved over the period. Clicks and impressions finally get their own scales on the traffic chart, so clicks no longer flatline against the impression counts. A "new queries" chip lists exactly which searches appeared this period, the page shows when your data last synced, and the view, page number, and expanded row all live in the URL.
- **The CDN page now shows what your cache is actually doing.** The bandwidth chart splits traffic into "served from cache" versus "fetched from your origin", with the cache hit rate for each day in the tooltip — so you can see your CDN earning its keep (or not). Requests get the same cached/uncached split, a new chart tracks your origin's response time over the period, error bars now use honest colors (redirects are no longer painted as errors), and the geographic breakdown is a ranked list of datacenters with their real share of traffic.
- **Funnels got a full page of their own.** Every funnel now opens as a real page with its own link you can share or bookmark. The list shows compact summary rows — the actual pages and events in each funnel, the conversion rate with a comparison to the previous period, and a small trend line — instead of expanding accordions. The detail page has a stats band (conversion, visitors, converted, biggest drop-off, median time), a visual funnel canvas where each step is a column filled to its conversion level with connecting bands between steps, and everything — date range, filters, selected step — lives in the URL so a refresh or a shared link lands exactly where you were.
- **Funnel editing no longer resets your conversion window.** Editing a funnel used to silently rewrite its conversion window to 30 days no matter what you had configured. The create/edit dialog now has a real window control (with 24h, 72h, 7d, 14d and 30d presets) that saves exactly what it shows.
- **Funnel steps suggest your real pages and events.** When building a funnel, the path field now suggests your most-visited pages and the event field suggests your tracked events — with visit counts — so you don't have to remember exact paths. Free text still works.
- **Journeys controls got out of the data's way.** The oversized control block is now one compact row — steppers for depth and paths, a searchable entry-point picker, and a Columns/Flow switch. Connections between steps are always visible as subtle ribbons, and hovering any page lights up its full path through the site.
- **Pin and share a journey path.** Clicking a page in either journeys view pins it as a "lens" — the whole chain through that page highlights, an exit card shows where people left, and the selection lives in the URL so you can share exactly what you're looking at. From a pinned lens you can create a funnel of that path in one click, and funnel steps link back to journeys starting from that page.
- **No more flashing while journeys and funnels load.** Changing depth, entry point, or date range used to swap the entire page to a loading skeleton on every tweak. Now the data stays visible and morphs to the new values, with a small "Updating…" chip when a fetch takes longer than usual. Failed loads show a clear error with a Retry button instead of pretending you have no data, and an entry filter with no matches says so instead of showing the generic empty state.
- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard.
- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything.
- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end.
Expand Down
19 changes: 13 additions & 6 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import { motion } from 'framer-motion'
import { CheckCircleIcon, XIcon } from '@ciphera-net/facet'

function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record<string, boolean | string> }[] }) {
// * Boolean cells render ✓/✗ and assume true = good. Where the raw boolean would invert
// * sentiment (e.g. "Cookie Banner Required" — false is the win), use { text, good } so
// * the colour follows the meaning, not the boolean.
type FeatureValue = boolean | string | { text: string; good: boolean }

function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record<string, FeatureValue> }[] }) {
const allFeatures = [
"Cookie Banner Required",
"GDPR Compliant",
Expand Down Expand Up @@ -36,7 +41,9 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
const val = comp.features[feature]
return (
<td key={comp.name} className="p-4 sm:p-6 text-sm sm:text-base">
{val === true ? (
{typeof val === 'object' ? (
<span className={`font-medium ${val.good ? 'text-green-500' : 'text-red-500'}`}>{val.text}</span>
) : val === true ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : val === false ? (
<XIcon className="w-5 h-5 text-red-500" />
Expand Down Expand Up @@ -103,7 +110,7 @@ export default function AboutPage() {
name: "Pulse",
isPulse: true,
features: {
"Cookie Banner Required": false,
"Cookie Banner Required": { text: "None", good: true },
"GDPR Compliant": true,
"Script Size": "< 1 KB",
"Data Ownership": "Yours",
Expand All @@ -115,7 +122,7 @@ export default function AboutPage() {
name: "Google Analytics 4",
isPulse: false,
features: {
"Cookie Banner Required": true,
"Cookie Banner Required": { text: "Required", good: false },
"GDPR Compliant": "Complex",
"Script Size": "45 KB+",
"Data Ownership": "Google's",
Expand All @@ -134,7 +141,7 @@ export default function AboutPage() {
name: "Pulse",
isPulse: true,
features: {
"Cookie Banner Required": false,
"Cookie Banner Required": { text: "None", good: true },
"GDPR Compliant": true,
"Script Size": "< 1 KB",
"Data Ownership": "Yours",
Expand All @@ -146,7 +153,7 @@ export default function AboutPage() {
name: "Plausible",
isPulse: false,
features: {
"Cookie Banner Required": false,
"Cookie Banner Required": { text: "None", good: true },
"GDPR Compliant": true,
"Script Size": "< 1 KB",
"Data Ownership": "Yours",
Expand Down
12 changes: 6 additions & 6 deletions app/admin/orgs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,14 @@ export default function AdminOrgDetailPage() {

<span className="text-neutral-500">Period End:</span>
<span className="font-medium">
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '—'}
</span>

<span className="text-neutral-500">Customer ID:</span>
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
<span className="font-mono text-xs">{org.billing_customer_id || '—'}</span>

<span className="text-neutral-500">Subscription ID:</span>
<span className="font-mono text-xs">{org.billing_subscription_id || '-'}</span>
<span className="font-mono text-xs">{org.billing_subscription_id || '—'}</span>
</div>
</div>

Expand Down Expand Up @@ -207,21 +207,21 @@ export default function AdminOrgDetailPage() {
<button
type="button"
onClick={() => setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
className="text-xs text-brand-orange hover:underline"
>
+1 Month
</button>
<button
type="button"
onClick={() => setPeriodEnd(addYears(new Date(), 1).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
className="text-xs text-brand-orange hover:underline"
>
+1 Year
</button>
<button
type="button"
onClick={() => setPeriodEnd(addYears(new Date(), 100).toISOString().slice(0, 16))}
className="text-xs text-blue-500 hover:underline"
className="text-xs text-brand-orange hover:underline"
>
Forever
</button>
Expand Down
2 changes: 1 addition & 1 deletion app/admin/orgs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default function AdminOrgsPage() {
</span>
</td>
<td className="px-4 py-3 text-neutral-300">
{org.subscription_status || '-'}
{org.subscription_status || '—'}
</td>
<td className="px-4 py-3 text-neutral-300">
{new Intl.NumberFormat().format(org.pageview_limit)}
Expand Down
4 changes: 2 additions & 2 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default function AdminDashboard() {
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link
href="/admin/orgs"
className="block bg-card border border-border transition-transform hover:scale-[1.02] rounded-none p-6 ease-apple"
className="group block bg-card border border-border transition-all hover:scale-[1.02] hover:border-neutral-600 rounded-none p-6 ease-apple"
>
<h3 className="text-lg font-semibold text-white">Organizations</h3>
<p className="text-sm text-neutral-400 mt-1">Manage organization plans and limits</p>
Expand All @@ -17,7 +17,7 @@ export default function AdminDashboard() {
</Link>
<Link
href="/admin/quarantine"
className="block bg-card border border-border transition-transform hover:scale-[1.02] rounded-none p-6 ease-apple"
className="group block bg-card border border-border transition-all hover:scale-[1.02] hover:border-neutral-600 rounded-none p-6 ease-apple"
>
<h3 className="text-lg font-semibold text-white">Quarantine & Reputation</h3>
<p className="text-sm text-neutral-400 mt-1">Monitor quarantined traffic and domain reputation</p>
Expand Down
4 changes: 2 additions & 2 deletions app/admin/quarantine/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ function EventsTab() {
{ev.path}
</td>
<td className="px-4 py-3 text-xs text-neutral-400 max-w-[180px] truncate">
{ev.referrer || '-'}
{ev.referrer || '—'}
</td>
<td className="px-4 py-3">
<Badge label={REASON_LABELS[ev.detection_reason] || ev.detection_reason} colorClass={REASON_COLORS[ev.detection_reason]} />
Expand Down Expand Up @@ -568,7 +568,7 @@ export default function AdminQuarantinePage() {
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
tab === t.key
? 'border-white text-white'
? 'border-brand-orange text-white'
: 'border-transparent text-neutral-400 hover:text-neutral-200'
} ease-apple`}
>
Expand Down
60 changes: 60 additions & 0 deletions app/api/favicon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'

/**
* Same-origin favicon proxy.
*
* Browsers previously loaded favicons straight from Google's s2 service,
* which sent every referrer/page domain — plus the user's IP — to Google
* from inside the authenticated app. The upstream fetch now happens
* server-side, so the only party that ever contacts Google is our origin
* on a CDN cache miss; the user's browser only talks to Pulse. CSP has
* dropped the google/gstatic allowances, so a regression fails loudly.
*/

const UPSTREAM = 'https://www.google.com/s2/favicons'

// * Sizes actually used by the app (see FAVICON_SERVICE_URL consumers).
const ALLOWED_SIZES = new Set(['16', '32', '64', '128'])

// * Bare hostname only — no scheme, port, path, or IP-literal shapes beyond
// * dotted labels. Anything else 400s and the <img> falls back to its icon.
const DOMAIN_RE = /^(?=.{4,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/

export async function GET(request: NextRequest) {
const domain = request.nextUrl.searchParams.get('domain')?.trim().toLowerCase() ?? ''
const sz = request.nextUrl.searchParams.get('sz') ?? '32'

if (!DOMAIN_RE.test(domain) || !ALLOWED_SIZES.has(sz)) {
return new NextResponse(null, { status: 400 })
}

try {
const upstream = await fetch(`${UPSTREAM}?domain=${encodeURIComponent(domain)}&sz=${sz}`, {
signal: AbortSignal.timeout(4000),
// * The CDN caches via the response headers below; keep Next's data
// * cache out of the way so misses don't accumulate on disk.
cache: 'no-store',
})
const contentType = upstream.headers.get('content-type') ?? ''
if (!upstream.ok || !contentType.startsWith('image/')) {
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=3600' },
})
}
const body = await upstream.arrayBuffer()
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800',
'X-Content-Type-Options': 'nosniff',
},
})
} catch {
// * Upstream unreachable/timed out — short-cache the miss to absorb storms.
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=300' },
})
}
}
7 changes: 6 additions & 1 deletion app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ function AuthCallbackContent() {
if (processedRef.current && !isRetrying) return

const code = searchParams.get('code')
if (!code) return
if (!code) {
// * No code param (stale link, prefetch, or a direct visit) — without an
// * error the loading overlay would spin forever.
setError('This sign-in link is missing its code — it may have expired. Please log in again.')
return
}

const state = searchParams.get('state')
const storedState = localStorage.getItem('oauth_state')
Expand Down
Loading