Skip to content

Pin Drop dashboard customisations - see CHANGES.md#338

Closed
andy-a wants to merge 1 commit intoOpenpanel-dev:mainfrom
PinDropApp:pindrop/dashboard-customisations
Closed

Pin Drop dashboard customisations - see CHANGES.md#338
andy-a wants to merge 1 commit intoOpenpanel-dev:mainfrom
PinDropApp:pindrop/dashboard-customisations

Conversation

@andy-a
Copy link
Copy Markdown

@andy-a andy-a commented Apr 15, 2026

No description provided.

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

This PR introduces comprehensive dashboard, API, and development tooling updates to Pin Drop OpenPanel. New profile and group widgets (source, platforms, top members), refactored profile properties display, sortable columns, enriched backend services with metrics, supporting utilities for source classification and date formatting, and seed/deployment documentation are integrated across the frontend, backend, and tooling layers.

Changes

Cohort / File(s) Summary
Documentation
CHANGES.md, DEPLOYMENT.md, LOCAL_SETUP.md
New comprehensive changelogs, deployment instructions, and local development setup guide documenting feature updates, deployment options, and troubleshooting.
Profile Source & Platforms Widgets
apps/start/src/components/profiles/profile-source.tsx, profile-platforms.tsx
New components rendering profile acquisition source channels (referrer, UTM classification, first-seen) and platform/browser usage distribution with session counts, percentages, and last-seen timestamps.
Group Platforms & Top Members Widgets
apps/start/src/components/groups/group-platforms.tsx, group-top-members.tsx
New components showing team-wide platform distribution (segmented bar + ranked list) and top 5 power users with event counts and last-active times.
Profile Properties Refactor
apps/start/src/components/profiles/profile-properties.tsx
Replaced tabbed UI with consolidated KeyValueGrid; added React Query fetching to resolve group IDs to names; filtered/normalized property display; computes subscriber status, plan, and location; excludes noisy/redundant keys.
Profile Detail Route
apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.tsx, _tabs.index.tsx
Added breadcrumb navigation with "Power user" badge; prefetch profile.source and profile.platforms queries; adjusted grid layout for new widgets.
Group Detail Route
apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx, _tabs.index.tsx
Added breadcrumb navigation; imported and prefetched group.platforms and group.topMembers queries; replaced inline properties with buildGroupInfoRows helper; updated metric cards to show total sessions and duration; added layout adjustments.
Groups List Route
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Removed "Add group" button from header; updated description to explain groups are created via billing integrations; retained modal/SDK integration comments.
Profile List Routes
apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.anonymous.tsx, identified.tsx, power-users.tsx
Integrated useDataTableSort hook; added sortBy/sortDirection parameters to trpc.profile.list and trpc.profile.powerUsers queries.
Table Column Definitions
apps/start/src/components/profiles/table/columns.tsx, apps/start/src/components/groups/table/columns.tsx
Profile table: replaced referrer column with plan (subscriber indicator + plan label); added SortableHeader for clickable sorting; new eventCount, totalDuration, firstSeenActivity, lastSeen columns with right-aligned formatting and em-dash fallbacks. Group table: replaced Type column with Plan column using normalized plan labels and custom badge styling.
Table Infrastructure
apps/start/src/components/profiles/table/index.tsx, apps/start/src/components/ui/data-table/data-table-hooks.tsx
Profile table: added SortingState and useDataTableSort integration with onSortingChange handler. New useDataTableSort hook: syncs manual sorting to URL query state via nuqs with sort/dir parameters; supports defaults and clearing.
Profile Event List & Layout Components
apps/start/src/components/events/event-list-item.tsx, apps/start/src/components/profiles/latest-events.tsx, most-events.tsx, popular-routes.tsx, profile-activity.tsx
Event list item: added optional hideProfile flag for conditional profile name rendering. Latest events: removed dynamic height measurement; added fixed max-h-[420px] and empty state. Most events & popular routes: render all items with max-h-[220px] overflow-y-auto constraint. Profile activity: grid changed from 2-column (4 months) to 3-column (3 months).
Profile Avatar & Metrics
apps/start/src/components/profiles/profile-avatar.tsx, profile-metrics.tsx
Avatar: added optional email prop; introduced useGravatarUrl hook for SHA-256-based Gravatar fallback. Metrics: replaced "Bounce Rate" with "Total Session Time" (min unit, not inverted); removed "Conversion Events"; updated "Revenue" visibility to always show.
UI Utilities & Layout
apps/start/src/components/ui/key-value-grid.tsx
Updated grid column sizing to use explicit minmax(0,1fr) tracks; added min-w-0 and overflow-hidden to cells and value containers for proper truncation of long values.
Utility Functions
apps/start/src/utils/casing.ts, date.ts, source.ts
Casing: enhanced camelCaseToWords to uppercase acronyms (OS, URL, ID, etc.). Date: new timeAgoShort() for terse relative times (secs, mins, hrs, mos, yrs). Source: new classifySource() function classifying referrer/UTM data into 8 channels (paid-search, organic-social, email, referral, direct, etc.) with platform/keyword extraction.
Backend Profile Service
packages/db/src/services/profile.service.ts
Extended IProfileMetrics with totalSessionDuration; added ProfileListSortBy, ProfileListSortDirection types and IEnrichedServiceProfile interface; introduced getEnrichedProfileList() joining profiles with metrics (eventCount, totalDuration, lastSeen, plan, isSubscriber); updated getProfileListCount() to query FINAL table.
Backend Group Service
packages/db/src/services/group.service.ts
Updated getGroupStats() to count members from profiles table (not uniq profile_id from events); fetch last_active_at in parallel from events; initialize result map with all requested groupIds and default values.
TRPC Profile Router
packages/trpc/src/routers/profile.ts
Added platforms() procedure (event aggregation by sdk_name with session/event counts, last-seen, app version, browser list, label). Added source() procedure (earliest session + top-10 referrer/UTM rollup). Updated list() and powerUsers() to use getEnrichedProfileList() and accept optional sortBy/sortDirection parameters.
TRPC Group Router
packages/trpc/src/routers/group.ts
Updated metrics() to include totalSessions and totalSessionDuration via parallel sessions aggregate. Added platforms() procedure (event aggregation by sdk_name with metrics, label, browsers). Added topMembers() procedure (top members by event count with profiles, eventCount, lastSeen).
Local Development
scripts/seed-local.mjs
New seed script for local instances: accepts CLIENT_ID/CLIENT_SECRET; creates synthetic team groups with subscription metadata; generates 220 profiles with randomized attributes; emits identify/track/revenue events with optional group assignments; includes error handling and progress reporting.

Sequence Diagram

sequenceDiagram
    participant Client as Browser/Client
    participant Router as TRPC Router
    participant Service as Profile/Group Service
    participant DB as ClickHouse Database
    
    Note over Client,DB: Enriched Profile/Group Data Flow (New Features)
    
    Client->>Router: profile.list (with sortBy, sortDirection)
    Router->>Service: getEnrichedProfileList(input)
    Service->>DB: Query profiles FINAL
    Service->>DB: Query session aggregates (eventCount, totalDuration, lastSeen)
    DB-->>Service: Return metrics per profile
    Service->>Service: Join & compute enrichments (plan, isSubscriber)
    Service-->>Router: Return IEnrichedServiceProfile[]
    Router-->>Client: Send enriched profile data
    
    Client->>Client: Render SortableHeader & columns<br/>(eventCount, totalDuration, plan)
    
    Note over Client,DB: Profile Source & Platforms Discovery
    Client->>Router: profile.source (profileId, projectId)
    Router->>DB: Query earliest session + referrer/UTM rollup
    DB-->>Router: Return acquisition data
    Router-->>Client: Return ClassifiedSource[]
    
    Client->>Router: profile.platforms (profileId, projectId)
    Router->>DB: Aggregate events by sdk_name (sessions, browsers, version)
    DB-->>Router: Return platform metrics
    Router-->>Client: Return platform distribution + stats
    
    Client->>Client: Render ProfileSource widget<br/>(ChannelIcon, CHANNEL_BADGE_CLASS)
    Client->>Client: Render ProfilePlatforms widget<br/>(segmented bar, session %)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Feature/groups #310: Implements parallel group feature development with overlapping modifications to group services, TRPC routers, and group components/routes.
  • Feature/mcp #331: Shares backend surface modifications for profile/group data enrichment and related TRPC procedures (platforms, topMembers, source).
  • Feature/performance2 #231: Modifies the same profile/group backend services and TRPC routers for enrichment and new query procedures.

Poem

🐰 New profiles bloom with source and platform shine,
Groups gather metrics, breadcrumbs mark the line,
Sortable columns dance, enriched data flows,
From ClickHouse queries blooms what OpenPanel knows! 🌱✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change as dashboard customisations for Pin Drop and references CHANGES.md for details, directly mapping to the PR's core objective of customising the OpenPanel dashboard UI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (18)
apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx (1)

104-110: Add an explicit breadcrumb label for accessibility.

Consider labeling the breadcrumb nav so screen readers can identify its purpose immediately.

Suggested tweak
-      <nav className="mb-2 flex items-center gap-1 text-sm text-muted-foreground">
+      <nav
+        aria-label="Breadcrumb"
+        className="mb-2 flex items-center gap-1 text-sm text-muted-foreground"
+      >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.groups_.$groupId._tabs.tsx
around lines 104 - 110, The breadcrumb nav lacks an explicit accessible label;
update the nav element (the <nav className="mb-2 flex items-center gap-1 text-sm
text-muted-foreground"> that wraps ProjectLink, ChevronRight and {g.name}) to
include an ARIA label or aria-labelledby (e.g., aria-label="Breadcrumb" or
aria-label="Breadcrumb navigation") so screen readers can identify it as a
breadcrumb region; ensure the label is concise and unique and keep the inner
structure (ProjectLink, ChevronRight, span with {g.name}) unchanged.
apps/start/src/components/ui/data-table/data-table-hooks.tsx (1)

35-43: Consider memoizing setSort with useCallback.

The setSort function is recreated on every render. If consuming components pass it to memoized children or include it in dependency arrays, this could cause unnecessary re-renders.

♻️ Proposed optimization
+import { useCallback, useEffect, useState } from 'react';
-import { useEffect, useState } from 'react';
...
+  const setSort = useCallback(
+    (next: { id: string; desc: boolean } | null) => {
+      if (!next) {
+        setSortBy('');
+        setDirection(defaultDirection);
+        return;
+      }
+      setSortBy(next.id);
+      setDirection(next.desc ? 'desc' : 'asc');
+    },
+    [setSortBy, setDirection, defaultDirection],
+  );
   return {
     sortBy: effectiveSortBy,
     sortDirection: direction,
-    setSort: (next: { id: string; desc: boolean } | null) => {
-      if (!next) {
-        setSortBy('');
-        setDirection(defaultDirection);
-        return;
-      }
-      setSortBy(next.id);
-      setDirection(next.desc ? 'desc' : 'asc');
-    },
+    setSort,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/ui/data-table/data-table-hooks.tsx` around lines 35
- 43, Wrap the setSort callback in React.useCallback to avoid recreating it on
every render: replace the inline setSort definition with a useCallback hook that
returns the same logic and list its dependencies (setSortBy, setDirection,
defaultDirection) so the memoized function updates correctly; also ensure
useCallback is imported from React if not already.
apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.anonymous.tsx (1)

41-42: Avoid as any type assertion for sortBy.

The as any cast bypasses type safety. If useDataTableSort returned a properly typed value matching the tRPC zSortBy schema, the cast wouldn't be needed. Consider typing the hook's return value to match the expected enum, or use a type guard/assertion function.

♻️ Suggested approach

If ProfileListSortBy is the expected type from the tRPC schema, consider updating the hook or adding a type-safe conversion:

// Option 1: Type the hook parameter
const { sortBy, sortDirection } = useDataTableSort<ProfileListSortBy>('createdAt', 'desc');

// Option 2: Use a type-safe assertion
sortBy: sortBy as ProfileListSortBy | undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.profiles._tabs.anonymous.tsx
around lines 41 - 42, The current use of "sortBy: (sortBy ?? undefined) as any"
bypasses type safety; update the type so the value matches the tRPC zSortBy/
ProfileListSortBy enum instead of casting to any. Fix by either typing the hook
return (e.g., make useDataTableSort generic or return ProfileListSortBy |
undefined so the destructured sortBy is already correctly typed) or replace the
cast with a narrow assertion such as sortBy as ProfileListSortBy | undefined, or
implement a small type-guard/assertion function that validates sortBy against
ProfileListSortBy before passing it to the query; reference symbols: sortBy,
useDataTableSort, zSortBy, ProfileListSortBy.
CHANGES.md (1)

223-224: Minor terminology update: "Bing Ads" is now "Microsoft Ads".

Microsoft rebranded Bing Ads to Microsoft Advertising (commonly referred to as "Microsoft Ads"). Consider updating for accuracy.

📝 Suggested fix
-  Reddit, X/Twitter Ads, Pinterest, Snapchat, Microsoft (Bing Ads),
+  Reddit, X/Twitter Ads, Pinterest, Snapchat, Microsoft Ads,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGES.md` around lines 223 - 224, Update the terminology fragment
"Microsoft (Bing Ads)" to use the current branding; replace that exact text with
"Microsoft Ads" (or "Microsoft Advertising (Microsoft Ads)" if you want the full
name) so the CHANGES entry reads e.g. "Reddit, X/Twitter Ads, Pinterest,
Snapchat, Microsoft Ads, YouTube."
LOCAL_SETUP.md (2)

30-32: Replace hardcoded path with a generic placeholder.

The path /Users/andy/Websites/openpanel is user-specific. Consider using a generic instruction:

-cd /Users/andy/Websites/openpanel
+cd <path-to-your-openpanel-clone>

Or simply:

cd openpanel  # wherever you cloned the repo

This makes the doc reusable if others need it later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@LOCAL_SETUP.md` around lines 30 - 32, Replace the hardcoded user-specific
path "cd /Users/andy/Websites/openpanel" in the LOCAL_SETUP.md bash example with
a generic placeholder such as "cd openpanel  # wherever you cloned the repo" (or
instruct users to replace with their own path) so the doc is reusable across
machines; update the code block that contains the "cd
/Users/andy/Websites/openpanel" line accordingly.

1-5: Consider generalizing the document title.

The title "for Andy" limits the document's reusability. If this is intended to be a general contributor guide, consider renaming to just "OpenPanel — Local Development Setup".

If it's intentionally personalized for a specific handoff, this is fine to leave as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@LOCAL_SETUP.md` around lines 1 - 5, The document title "OpenPanel — Local
Development Setup (for Andy)" is overly personalized; update the top-level
header text to "OpenPanel — Local Development Setup" to generalize it for all
contributors (replace the exact string "OpenPanel — Local Development Setup (for
Andy)" in the file). If the personalization was intentional, add a short note
below the header clarifying the audience instead of embedding a name in the
title.
packages/db/src/services/group.service.ts (1)

272-293: Consider using the custom query builder for ClickHouse queries.

The refactored queries use raw SQL strings with chQuery. As per coding guidelines, ClickHouse queries should use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts and ./packages/db/src/clickhouse/query-functions.ts.

That said, the existing codebase in this file already uses raw SQL throughout, so this is consistent with the current pattern. If the team wants to migrate to the query builder, it could be done in a follow-up.

The logic itself is correct — counting members from the profiles table with ARRAY JOIN groups ensures consistency with the Members tab display.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/services/group.service.ts` around lines 272 - 293, The two
raw ClickHouse calls using chQuery that populate memberRows and activityRows
(the SELECTs against TABLE_NAMES.profiles and TABLE_NAMES.events using ARRAY
JOIN groups and escapedIds/projectId) should be rewritten to use the project's
ClickHouse query builder and query-functions instead of raw SQL strings; replace
the chQuery<{...}>(`...`) invocations that build the member count and
last_active_at queries with the corresponding builder API from
clickhouse/query-builder.ts and helper functions from
clickhouse/query-functions.ts, passing projectId and the escaped group IDs via
the builder methods so parameters are safely composed and the queries follow the
shared query-building conventions.
apps/start/src/components/profiles/profile-source.tsx (2)

23-39: Consider using a lookup object instead of nested ternaries.

The deeply nested ternary is hard to read and maintain. A lookup object would be cleaner:

♻️ Suggested refactor
+const CHANNEL_ICONS: Record<SourceChannel, typeof Globe> = {
+  'paid-search': Megaphone,
+  'paid-social': Megaphone,
+  'paid-video': Video,
+  'organic-search': Search,
+  email: Mail,
+  'organic-social': Share2,
+  referral: Globe,
+  direct: LinkIcon,
+};
+
 function ChannelIcon({ channel }: { channel: SourceChannel }) {
-  const Icon =
-    channel === 'paid-search' || channel === 'paid-social'
-      ? Megaphone
-      : channel === 'paid-video'
-        ? Video
-        : ... // truncated
+  const Icon = CHANNEL_ICONS[channel] ?? LinkIcon;
   return <Icon className="size-4 shrink-0 text-muted-foreground" />;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/profiles/profile-source.tsx` around lines 23 - 39,
Replace the nested ternary in ChannelIcon with a clear lookup map: create an
object mapping SourceChannel values
('paid-search','paid-social','paid-video','organic-search','email','organic-social','referral')
to the corresponding icon symbols (Megaphone, Video, Search, Mail, Share2,
Globe) and use LinkIcon as the default fallback (e.g., const ICON_MAP = { ... };
const Icon = ICON_MAP[channel] || LinkIcon;), then return <Icon ... />
unchanged; this keeps the ChannelIcon component and prop typing intact while
improving readability and maintainability.

52-76: Same refactor opportunity for ChannelBadge.

Similarly, the readable label mapping could use a lookup:

♻️ Suggested refactor
+const CHANNEL_LABELS: Record<SourceChannel, string> = {
+  'paid-search': 'Paid search',
+  'paid-social': 'Paid social',
+  'paid-video': 'Paid video',
+  'organic-search': 'Organic search',
+  'organic-social': 'Organic social',
+  email: 'Email',
+  referral: 'Referral',
+  direct: 'Direct',
+};
+
 function ChannelBadge({ channel }: { channel: SourceChannel }) {
-  const readable =
-    channel === 'paid-search'
-      ? 'Paid search'
-      : ... // truncated
+  const readable = CHANNEL_LABELS[channel] ?? 'Unknown';
   return (
     <span className={...}>
       {readable}
     </span>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/profiles/profile-source.tsx` around lines 52 - 76,
The ChannelBadge component currently uses a long nested ternary to derive a
human-readable label; replace that with a lookup object (e.g., const
CHANNEL_LABELS: Record<SourceChannel, string>) and use CHANNEL_LABELS[channel]
(with a fallback like 'Direct') to compute readable; update ChannelBadge to
reference the new CHANNEL_LABELS mapping and keep the existing
CHANNEL_BADGE_CLASS[channel] usage to preserve styling.
apps/start/src/utils/date.ts (1)

67-87: Consider handling "days" and "weeks" for consistency.

The function normalizes most time units (seconds, minutes, hours, months, years) but doesn't handle days or weeks. Depending on your UI constraints, you might want to add:

    .replace(/\byears?\b/g, 'yrs')
+   .replace(/\bweeks?\b/g, 'wks')
+   .replace(/\bdays?\b/g, 'days') // or 'd' if you want it shorter

This is minor since "days" and "weeks" are already fairly short, but flagging for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/utils/date.ts` around lines 67 - 87, The timeAgoShort function
(using ta.format) currently normalizes seconds/minutes/hours/months/years but
omits days and weeks; update timeAgoShort to also replace full words "days" and
"weeks" with short forms (e.g., "days" -> "dys" or "days" -> "days" depending on
your chosen short form) and handle any locale-specific abbreviated forms with
trailing periods (e.g., "day." / "wk.") and add the singular-case replacement
analogous to the existing .replace(/\b1 (secs|mins|hrs|mos|yrs)\b/g, ...) to
drop the plural "s" for "1 day" and "1 week"; make changes in the timeAgoShort
function around the other .replace calls so days/weeks follow the same pattern
and naming conventions as the other units.
apps/start/src/components/groups/group-platforms.tsx (1)

57-67: Minor: Potential duplicate keys in the bar chart.

If two different SDKs map to the same label (e.g., both swift and ios SDK names map to "iOS"), the key bar-${p.label} would be duplicated, causing a React warning.

Consider using a unique identifier:

-key={`bar-${p.label}`}
+key={`bar-${p.sdkName ?? p.label}`}

This is unlikely to occur in practice, but worth noting for robustness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/groups/group-platforms.tsx` around lines 57 - 67,
The bar elements use a non-unique key `bar-${p.label}` in the data.map callback
which can duplicate if different SDK entries share the same label; update the
key to be unique by combining the label with another stable identifier (e.g.,
`p.id`, `p.key`, or the map index) used by the data source so the key becomes
something like `bar-${p.label}-${p.id}` or `bar-${p.label}-${index}`; locate the
map over `data` in group-platforms.tsx and replace the `key` prop on the
returned div accordingly.
apps/start/src/utils/source.ts (1)

195-203: Operator precedence may cause unexpected behavior.

The condition referrerType === 'social' || referrerName && /social/i.test(referrerName) relies on JavaScript's operator precedence where && binds tighter than ||. While this works correctly, adding parentheses improves readability and prevents accidental bugs if the condition is modified later.

♻️ Suggested clarification
-  if (referrerType === 'social' || referrerName && /social/i.test(referrerName)) {
+  if (referrerType === 'social' || (referrerName && /social/i.test(referrerName))) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/utils/source.ts` around lines 195 - 203, The boolean
expression using referrerType and referrerName relies on && binding tighter than
||; update the conditional in the block that builds the organic-social object
(the if using referrerType === 'social' || referrerName &&
/social/i.test(referrerName)) to add explicit parentheses so the intent is clear
(e.g. referrerType === 'social' || (referrerName &&
/social/i.test(referrerName))); no other logic changes required—just group the
second operand to improve readability and prevent future precedence mistakes.
packages/trpc/src/routers/group.ts (1)

102-118: Consider performance implications of correlated subquery for large groups.

The profile_id IN (SELECT ...) pattern requires ClickHouse to materialize the inner subquery for each row evaluation. For groups with many members, this could be slow compared to a JOIN-based approach.

If performance becomes an issue, consider rewriting as a JOIN:

♻️ Alternative using JOIN
-        chQuery<{ totalSessions: number; totalSessionDuration: number }>(`
-          SELECT
-            count() AS totalSessions,
-            -- minutes
-            round(sum(duration) / 60, 2) AS totalSessionDuration
-          FROM ${TABLE_NAMES.sessions} FINAL
-          WHERE project_id = ${sqlstring.escape(projectId)}
-            AND profile_id IN (
-              SELECT id FROM ${TABLE_NAMES.profiles} FINAL
-              WHERE project_id = ${sqlstring.escape(projectId)}
-                AND has(groups, ${sqlstring.escape(id)})
-            )
-        `),
+        chQuery<{ totalSessions: number; totalSessionDuration: number }>(`
+          SELECT
+            count() AS totalSessions,
+            round(sum(s.duration) / 60, 2) AS totalSessionDuration
+          FROM ${TABLE_NAMES.sessions} AS s FINAL
+          INNER JOIN (
+            SELECT id FROM ${TABLE_NAMES.profiles} FINAL
+            WHERE project_id = ${sqlstring.escape(projectId)}
+              AND has(groups, ${sqlstring.escape(id)})
+          ) AS p ON s.profile_id = p.id
+          WHERE s.project_id = ${sqlstring.escape(projectId)}
+        `),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/group.ts` around lines 102 - 118, The current
chQuery in group router uses a correlated subquery (profile_id IN (SELECT id
FROM ${TABLE_NAMES.profiles} ...)) which can be slow for large groups; replace
that IN-subquery with an explicit JOIN between ${TABLE_NAMES.sessions} and
${TABLE_NAMES.profiles} (e.g., alias sessions and profiles) filtering on
project_id and has(groups, ${sqlstring.escape(id)}) to compute totalSessions and
totalSessionDuration, ensuring you still use the same projectId and id variables
and keep the FINAL table modifier and aggregation logic in the chQuery call in
packages/trpc/src/routers/group.ts.
apps/start/src/components/profiles/table/columns.tsx (1)

138-143: Minor inconsistency: eventCount shows 0 while other columns show for missing values.

The eventCount column displays 0 for missing values (via ?? 0), while totalDuration, firstSeenActivity, and lastSeen show . Consider whether 0 is the intended display for profiles with no events.

If 0 is intentional (a profile with no events truly has 0 events), this is fine. If it should match the dash pattern:

♻️ Optional: match dash pattern
       cell: ({ row }) => (
         <div className="text-right font-mono tabular-nums">
-          {row.original.eventCount?.toLocaleString() ?? 0}
+          {row.original.eventCount ? row.original.eventCount.toLocaleString() : '—'}
         </div>
       ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/profiles/table/columns.tsx` around lines 138 - 143,
Change the eventCount cell to use the same missing-value dash as other columns
instead of defaulting to 0: in the cell renderer (cell: ({ row }) => ...), check
for null/undefined on row.original.eventCount and render
row.original.eventCount.toLocaleString() only when present, otherwise render '—'
(i.e., replace the current `?? 0` fallback logic with an explicit null check and
the dash fallback).
apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx (1)

17-26: Consider using Intl.DateTimeFormat for locale-aware date formatting.

The manual DD/MM/YYYY format works, but Intl.DateTimeFormat would be more maintainable and could respect user locale if needed in the future.

♻️ Optional refactor
 function formatDateShort(value: string | Date | null | undefined): string {
   if (!value) return '—';
   const d = value instanceof Date ? value : new Date(value);
   if (Number.isNaN(d.getTime())) return '—';
-  const dd = String(d.getDate()).padStart(2, '0');
-  const mm = String(d.getMonth() + 1).padStart(2, '0');
-  const yyyy = d.getFullYear();
-  return `${dd}/${mm}/${yyyy}`;
+  return new Intl.DateTimeFormat('en-GB', {
+    day: '2-digit',
+    month: '2-digit',
+    year: 'numeric',
+  }).format(d);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx
around lines 17 - 26, The formatDateShort function manually constructs
DD/MM/YYYY strings; replace that logic with Intl.DateTimeFormat to produce the
same DD/MM/YYYY output while keeping the existing null/invalid handling: in
formatDateShort, convert value to a Date, return '—' for falsy/invalid dates,
then format with new Intl.DateTimeFormat('en-GB', { day: '2-digit', month:
'2-digit', year: 'numeric' }) (or use a locale variable if you want
locale-awareness later) to generate the final string; keep the function
signature and return behavior unchanged.
packages/trpc/src/routers/profile.ts (3)

69-92: Consider using argMax for consistent OS selection.

Line 71 uses any(os) which picks an arbitrary OS value from the group. Since you're already using argMax for app_version and build_number to get the most recent values, consider using the same approach for os to ensure consistency.

♻️ Suggested change
         SELECT
           sdk_name,
-          any(os) as os,
+          argMax(os, created_at) as os,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/profile.ts` around lines 69 - 92, The query in
profile.ts uses any(os) which can return an arbitrary OS; replace it with
argMax(os, created_at) to pick the OS from the most recent event for each
sdk_name so it matches how app_version and build_number are selected; update the
SELECT to use argMax(os, created_at) (keeping created_at already used elsewhere)
and ensure grouping/ordering remain the same for functions like groupUniqArray,
argMax(properties['__version'], created_at), and
argMax(properties['__buildNumber'], created_at).

121-121: Inconsistent date return types across procedures.

The new platforms and source procedures return dates as raw strings (e.g., lastSeen: r.last_seen, createdAt: first.created_at), while getEnrichedProfileList used by list and powerUsers returns Date objects via convertClickhouseDateToJs. This may cause inconsistent handling in the UI layer.

Consider normalizing to return Date objects for consistency with the rest of the profile router.

Also applies to: 194-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/profile.ts` at line 121, The platforms and source
procedures are returning raw date strings (e.g., mapping lastSeen: r.last_seen
and createdAt: first.created_at) while getEnrichedProfileList returns Date
objects; update the platforms and source result mappings to convert ClickHouse
date strings to JS Dates using the existing convertClickhouseDateToJs helper so
lastSeen and createdAt (and any other *_at / last_* fields) return Date objects
like getEnrichedProfileList; locate the mappings inside the platforms and source
procedure handlers and replace direct field assignments with calls to
convertClickhouseDateToJs(r.last_seen) and
convertClickhouseDateToJs(first.created_at) (and similar fields mentioned at
lines 121 and 194).

56-93: Raw SQL query instead of custom query builder.

The coding guidelines specify using the custom query builder from ./packages/db/src/clickhouse/query-builder.ts for ClickHouse queries. However, I note that existing procedures in this file (activity, mostEvents, popularRoutes) also use raw SQL with chQuery. If this is an intentional pattern for this router, please confirm; otherwise, consider refactoring to use createSqlBuilder.

As per coding guidelines: "When writing ClickHouse queries, always use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/profile.ts` around lines 56 - 93, The platforms
procedure currently uses a raw SQL string passed to chQuery (see
platforms/protectedProcedure and chQuery with TABLE_NAMES.events), which
violates the guideline to use the ClickHouse custom query builder; refactor this
to use createSqlBuilder from ./packages/db/src/clickhouse/query-builder.ts to
build the same SELECT (sdk_name, any(os) as os, arrayFilter/... as browsers,
count(distinct session_id) as sessions, count() as events, max(created_at) as
last_seen, argMax(properties['__version'], created_at) as app_version,
argMax(properties['__buildNumber'], created_at) as build_number) against
TABLE_NAMES.events, and pass parameters (profileId, projectId) through the
builder (not string interpolation) before calling chQuery; update the other
procedures in this file (activity, mostEvents, popularRoutes) similarly if they
still use raw SQL to ensure consistent, parameterized query construction via
createSqlBuilder.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/start/src/components/profiles/profile-avatar.tsx`:
- Around line 86-91: The current avatar selection uses a prefix-based
isValidAvatar check that can accept malformed or insecure schemes; update the
validation used before prioritizing the explicit avatar so it parses the string
as a URL and only treats it as valid when new URL(avatar) succeeds and
url.protocol === 'https:' (and optionally url.hostname is non-empty), then use
that tightened check in the branch that sets src (where useGravatarUrl,
isValidAvatar, avatar, gravatarUrl and src are referenced) so only fully-formed
HTTPS URLs override Gravatar; if parsing fails or protocol isn't 'https:' fall
back to gravatarUrl.

In `@apps/start/src/components/profiles/profile-platforms.tsx`:
- Around line 77-131: The list rendered in data.map uses p.label as the React
key which can collide; change the key on the <li> to a stable unique identifier
(for example use p.id if available, or build a composite key like
`${p.label}-${p.appVersion ?? 'web'}-${p.buildNumber ?? ''}`) instead of p.label
so each <li> rendered by the data.map call has a truly unique key; update the
key usage in the JSX returned by data.map (the <li key=...> in this component)
accordingly.
- Around line 63-73: The current bar segment key uses `p.label` which can
collide when `friendlyLabel` maps multiple `sdk_name` values to the same label;
update the key in the `data.map` rendering (the map callback where
`key={`bar-${p.label}`}` is set) to include a unique identifier such as
`p.sdkName` or a combination like `bar-${p.sdkName}-${p.label}` so each segment
is uniquely keyed and avoids React key collisions.

In `@apps/start/src/components/profiles/profile-properties.tsx`:
- Around line 47-62: The component ProfileProperties currently dereferences
profile (e.g., profile.projectId, profile.groups) before any JSX guard so a
null/undefined profile will throw; either make the prop required in the Props
type or add an early return guard at the top of ProfileProperties (before
useTRPC/useQuery usage) that returns the "No profile data" fallback when profile
is nullish; also apply the same early-guard fix for the later usage referenced
around the block that corresponds to lines 167-175 so no further code
dereferences profile after a null check.
- Around line 64-67: The code assigns `team` by falling back to the first group
if no group has type 'team', which mislabels non-team groups; change the `team`
assignment to only pick groupsQuery.data?.find((g) => g.type?.toLowerCase() ===
'team') and otherwise set `team` to null (remove the `groupsQuery.data?.[0]`
fallback), then update any places that render `teamName` (e.g., where you read
`team?.name` around the current `team` usage and the other occurrence at the
later usage) to handle null/undefined (show empty string or a proper
placeholder) instead of implicitly trusting a fallback group.

In `@DEPLOYMENT.md`:
- Around line 154-167: The docker push commands use hardcoded tags (e.g.,
ghcr.io/pindrop/openpanel-dashboard:custom-20260416) that don’t match the build
tags which use $(date +%Y%m%d); fix by generating a single TAG variable or
reusing $(date +%Y%m%d) so build and push use the same tag. Specifically, set
TAG=$(date +%Y%m%d) (or use the same command substitution) and replace the
hardcoded tags in the docker push lines so they match the tags emitted by the
docker build lines.

In `@packages/db/src/services/profile.service.ts`:
- Around line 288-290: The search filter currently interpolates `search`
directly into `filterSearch`, causing SQL injection; change the logic in the
`filterSearch` construction to use a parameterized placeholder and a params
array (e.g., add a parameter like `'%'+search+'%'` or use SQL concatenation with
`ILIKE '%' || $n || '%'`) instead of string interpolation, update the query
execution to pass that params array, and apply the same pattern to the other
occurrences in `getProfileList` and `getProfileListCount` so that `search` is
always bound as a query parameter rather than injected into the SQL string.
- Around line 304-313: The session duration units are inconsistent:
getProfileMetrics returns minutes while getEnrichedProfileList's session_stats
CTE (the SUM on ${TABLE_NAMES.sessions} via sum(duration) AS total_duration)
returns raw milliseconds; update the session_stats aggregation in
getEnrichedProfileList to convert milliseconds to minutes (matching
getProfileMetrics), e.g. divide sum(duration) by 1000 and 60 and round
similarly, so total_duration uses the same minutes unit and precision as
getProfileMetrics.

---

Nitpick comments:
In `@apps/start/src/components/groups/group-platforms.tsx`:
- Around line 57-67: The bar elements use a non-unique key `bar-${p.label}` in
the data.map callback which can duplicate if different SDK entries share the
same label; update the key to be unique by combining the label with another
stable identifier (e.g., `p.id`, `p.key`, or the map index) used by the data
source so the key becomes something like `bar-${p.label}-${p.id}` or
`bar-${p.label}-${index}`; locate the map over `data` in group-platforms.tsx and
replace the `key` prop on the returned div accordingly.

In `@apps/start/src/components/profiles/profile-source.tsx`:
- Around line 23-39: Replace the nested ternary in ChannelIcon with a clear
lookup map: create an object mapping SourceChannel values
('paid-search','paid-social','paid-video','organic-search','email','organic-social','referral')
to the corresponding icon symbols (Megaphone, Video, Search, Mail, Share2,
Globe) and use LinkIcon as the default fallback (e.g., const ICON_MAP = { ... };
const Icon = ICON_MAP[channel] || LinkIcon;), then return <Icon ... />
unchanged; this keeps the ChannelIcon component and prop typing intact while
improving readability and maintainability.
- Around line 52-76: The ChannelBadge component currently uses a long nested
ternary to derive a human-readable label; replace that with a lookup object
(e.g., const CHANNEL_LABELS: Record<SourceChannel, string>) and use
CHANNEL_LABELS[channel] (with a fallback like 'Direct') to compute readable;
update ChannelBadge to reference the new CHANNEL_LABELS mapping and keep the
existing CHANNEL_BADGE_CLASS[channel] usage to preserve styling.

In `@apps/start/src/components/profiles/table/columns.tsx`:
- Around line 138-143: Change the eventCount cell to use the same missing-value
dash as other columns instead of defaulting to 0: in the cell renderer (cell: ({
row }) => ...), check for null/undefined on row.original.eventCount and render
row.original.eventCount.toLocaleString() only when present, otherwise render '—'
(i.e., replace the current `?? 0` fallback logic with an explicit null check and
the dash fallback).

In `@apps/start/src/components/ui/data-table/data-table-hooks.tsx`:
- Around line 35-43: Wrap the setSort callback in React.useCallback to avoid
recreating it on every render: replace the inline setSort definition with a
useCallback hook that returns the same logic and list its dependencies
(setSortBy, setDirection, defaultDirection) so the memoized function updates
correctly; also ensure useCallback is imported from React if not already.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx:
- Around line 17-26: The formatDateShort function manually constructs DD/MM/YYYY
strings; replace that logic with Intl.DateTimeFormat to produce the same
DD/MM/YYYY output while keeping the existing null/invalid handling: in
formatDateShort, convert value to a Date, return '—' for falsy/invalid dates,
then format with new Intl.DateTimeFormat('en-GB', { day: '2-digit', month:
'2-digit', year: 'numeric' }) (or use a locale variable if you want
locale-awareness later) to generate the final string; keep the function
signature and return behavior unchanged.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.groups_.$groupId._tabs.tsx:
- Around line 104-110: The breadcrumb nav lacks an explicit accessible label;
update the nav element (the <nav className="mb-2 flex items-center gap-1 text-sm
text-muted-foreground"> that wraps ProjectLink, ChevronRight and {g.name}) to
include an ARIA label or aria-labelledby (e.g., aria-label="Breadcrumb" or
aria-label="Breadcrumb navigation") so screen readers can identify it as a
breadcrumb region; ensure the label is concise and unique and keep the inner
structure (ProjectLink, ChevronRight, span with {g.name}) unchanged.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.profiles._tabs.anonymous.tsx:
- Around line 41-42: The current use of "sortBy: (sortBy ?? undefined) as any"
bypasses type safety; update the type so the value matches the tRPC zSortBy/
ProfileListSortBy enum instead of casting to any. Fix by either typing the hook
return (e.g., make useDataTableSort generic or return ProfileListSortBy |
undefined so the destructured sortBy is already correctly typed) or replace the
cast with a narrow assertion such as sortBy as ProfileListSortBy | undefined, or
implement a small type-guard/assertion function that validates sortBy against
ProfileListSortBy before passing it to the query; reference symbols: sortBy,
useDataTableSort, zSortBy, ProfileListSortBy.

In `@apps/start/src/utils/date.ts`:
- Around line 67-87: The timeAgoShort function (using ta.format) currently
normalizes seconds/minutes/hours/months/years but omits days and weeks; update
timeAgoShort to also replace full words "days" and "weeks" with short forms
(e.g., "days" -> "dys" or "days" -> "days" depending on your chosen short form)
and handle any locale-specific abbreviated forms with trailing periods (e.g.,
"day." / "wk.") and add the singular-case replacement analogous to the existing
.replace(/\b1 (secs|mins|hrs|mos|yrs)\b/g, ...) to drop the plural "s" for "1
day" and "1 week"; make changes in the timeAgoShort function around the other
.replace calls so days/weeks follow the same pattern and naming conventions as
the other units.

In `@apps/start/src/utils/source.ts`:
- Around line 195-203: The boolean expression using referrerType and
referrerName relies on && binding tighter than ||; update the conditional in the
block that builds the organic-social object (the if using referrerType ===
'social' || referrerName && /social/i.test(referrerName)) to add explicit
parentheses so the intent is clear (e.g. referrerType === 'social' ||
(referrerName && /social/i.test(referrerName))); no other logic changes
required—just group the second operand to improve readability and prevent future
precedence mistakes.

In `@CHANGES.md`:
- Around line 223-224: Update the terminology fragment "Microsoft (Bing Ads)" to
use the current branding; replace that exact text with "Microsoft Ads" (or
"Microsoft Advertising (Microsoft Ads)" if you want the full name) so the
CHANGES entry reads e.g. "Reddit, X/Twitter Ads, Pinterest, Snapchat, Microsoft
Ads, YouTube."

In `@LOCAL_SETUP.md`:
- Around line 30-32: Replace the hardcoded user-specific path "cd
/Users/andy/Websites/openpanel" in the LOCAL_SETUP.md bash example with a
generic placeholder such as "cd openpanel  # wherever you cloned the repo" (or
instruct users to replace with their own path) so the doc is reusable across
machines; update the code block that contains the "cd
/Users/andy/Websites/openpanel" line accordingly.
- Around line 1-5: The document title "OpenPanel — Local Development Setup (for
Andy)" is overly personalized; update the top-level header text to "OpenPanel —
Local Development Setup" to generalize it for all contributors (replace the
exact string "OpenPanel — Local Development Setup (for Andy)" in the file). If
the personalization was intentional, add a short note below the header
clarifying the audience instead of embedding a name in the title.

In `@packages/db/src/services/group.service.ts`:
- Around line 272-293: The two raw ClickHouse calls using chQuery that populate
memberRows and activityRows (the SELECTs against TABLE_NAMES.profiles and
TABLE_NAMES.events using ARRAY JOIN groups and escapedIds/projectId) should be
rewritten to use the project's ClickHouse query builder and query-functions
instead of raw SQL strings; replace the chQuery<{...}>(`...`) invocations that
build the member count and last_active_at queries with the corresponding builder
API from clickhouse/query-builder.ts and helper functions from
clickhouse/query-functions.ts, passing projectId and the escaped group IDs via
the builder methods so parameters are safely composed and the queries follow the
shared query-building conventions.

In `@packages/trpc/src/routers/group.ts`:
- Around line 102-118: The current chQuery in group router uses a correlated
subquery (profile_id IN (SELECT id FROM ${TABLE_NAMES.profiles} ...)) which can
be slow for large groups; replace that IN-subquery with an explicit JOIN between
${TABLE_NAMES.sessions} and ${TABLE_NAMES.profiles} (e.g., alias sessions and
profiles) filtering on project_id and has(groups, ${sqlstring.escape(id)}) to
compute totalSessions and totalSessionDuration, ensuring you still use the same
projectId and id variables and keep the FINAL table modifier and aggregation
logic in the chQuery call in packages/trpc/src/routers/group.ts.

In `@packages/trpc/src/routers/profile.ts`:
- Around line 69-92: The query in profile.ts uses any(os) which can return an
arbitrary OS; replace it with argMax(os, created_at) to pick the OS from the
most recent event for each sdk_name so it matches how app_version and
build_number are selected; update the SELECT to use argMax(os, created_at)
(keeping created_at already used elsewhere) and ensure grouping/ordering remain
the same for functions like groupUniqArray, argMax(properties['__version'],
created_at), and argMax(properties['__buildNumber'], created_at).
- Line 121: The platforms and source procedures are returning raw date strings
(e.g., mapping lastSeen: r.last_seen and createdAt: first.created_at) while
getEnrichedProfileList returns Date objects; update the platforms and source
result mappings to convert ClickHouse date strings to JS Dates using the
existing convertClickhouseDateToJs helper so lastSeen and createdAt (and any
other *_at / last_* fields) return Date objects like getEnrichedProfileList;
locate the mappings inside the platforms and source procedure handlers and
replace direct field assignments with calls to
convertClickhouseDateToJs(r.last_seen) and
convertClickhouseDateToJs(first.created_at) (and similar fields mentioned at
lines 121 and 194).
- Around line 56-93: The platforms procedure currently uses a raw SQL string
passed to chQuery (see platforms/protectedProcedure and chQuery with
TABLE_NAMES.events), which violates the guideline to use the ClickHouse custom
query builder; refactor this to use createSqlBuilder from
./packages/db/src/clickhouse/query-builder.ts to build the same SELECT
(sdk_name, any(os) as os, arrayFilter/... as browsers, count(distinct
session_id) as sessions, count() as events, max(created_at) as last_seen,
argMax(properties['__version'], created_at) as app_version,
argMax(properties['__buildNumber'], created_at) as build_number) against
TABLE_NAMES.events, and pass parameters (profileId, projectId) through the
builder (not string interpolation) before calling chQuery; update the other
procedures in this file (activity, mostEvents, popularRoutes) similarly if they
still use raw SQL to ensure consistent, parameterized query construction via
createSqlBuilder.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6324d246-4dbf-488f-8fde-795dfcd94eb9

📥 Commits

Reviewing files that changed from the base of the PR and between 17c6a91 and 23be478.

📒 Files selected for processing (37)
  • CHANGES.md
  • DEPLOYMENT.md
  • LOCAL_SETUP.md
  • apps/start/src/components/events/event-list-item.tsx
  • apps/start/src/components/groups/group-platforms.tsx
  • apps/start/src/components/groups/group-top-members.tsx
  • apps/start/src/components/groups/table/columns.tsx
  • apps/start/src/components/overview/overview-metric-card.tsx
  • apps/start/src/components/profiles/latest-events.tsx
  • apps/start/src/components/profiles/most-events.tsx
  • apps/start/src/components/profiles/popular-routes.tsx
  • apps/start/src/components/profiles/profile-activity.tsx
  • apps/start/src/components/profiles/profile-avatar.tsx
  • apps/start/src/components/profiles/profile-metrics.tsx
  • apps/start/src/components/profiles/profile-platforms.tsx
  • apps/start/src/components/profiles/profile-properties.tsx
  • apps/start/src/components/profiles/profile-source.tsx
  • apps/start/src/components/profiles/table/columns.tsx
  • apps/start/src/components/profiles/table/index.tsx
  • apps/start/src/components/ui/data-table/data-table-hooks.tsx
  • apps/start/src/components/ui/key-value-grid.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.anonymous.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.identified.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.power-users.tsx
  • apps/start/src/utils/casing.ts
  • apps/start/src/utils/date.ts
  • apps/start/src/utils/source.ts
  • packages/db/src/services/group.service.ts
  • packages/db/src/services/profile.service.ts
  • packages/trpc/src/routers/group.ts
  • packages/trpc/src/routers/profile.ts
  • scripts/seed-local.mjs

Comment thread apps/start/src/components/profiles/profile-avatar.tsx
Comment thread apps/start/src/components/profiles/profile-platforms.tsx
Comment thread apps/start/src/components/profiles/profile-platforms.tsx
Comment thread apps/start/src/components/profiles/profile-properties.tsx
Comment thread apps/start/src/components/profiles/profile-properties.tsx
Comment thread DEPLOYMENT.md
Comment thread packages/db/src/services/profile.service.ts
Comment thread packages/db/src/services/profile.service.ts
@andy-a andy-a closed this Apr 15, 2026
@andy-a andy-a deleted the pindrop/dashboard-customisations branch April 15, 2026 20:11
@andy-a andy-a restored the pindrop/dashboard-customisations branch April 15, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants