Skip to content

feat: add publishing graph to posted ui#54

Open
mezotv wants to merge 2 commits intojoschan21:mainfrom
mezotv:feat/publishing-activity-graph
Open

feat: add publishing graph to posted ui#54
mezotv wants to merge 2 commits intojoschan21:mainfrom
mezotv:feat/publishing-activity-graph

Conversation

@mezotv
Copy link
Copy Markdown
Contributor

@mezotv mezotv commented Sep 16, 2025

Adds a github like graph to the dashboard showing the tweet activity ^^

Screenshot

image

PLEASE TEST BEFORE MERGING!

Summary by CodeRabbit

  • New Features
    • Interactive publishing activity heatmap added to the Posted page.
    • Per-day tooltips, year summary, and total tweet count displayed.
    • Legend for activity intensity and light/dark theme support.
    • Loading skeleton while fetching data.
    • Blurred preview and notice when real activity data isn’t available.

@vercel
Copy link
Copy Markdown

vercel bot commented Sep 16, 2025

@mezotv is attempting to deploy a commit to the joschan21's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Sep 16, 2025

Walkthrough

Adds a ContributionGraph UI, a PostGraph client component to the Posted page, a new postedRouter with activity/stats/recent endpoints, and wires the router into the app router. PostGraph fetches yearly activity, renders a heatmap with loading/fallback fake data, tooltips, legend, and totals.

Changes

Cohort / File(s) Summary of Changes
Page integration
src/app/studio/posted/page.tsx
Imports and renders PostGraph within the Posted page layout; no API/signature changes.
Posted feature component
src/components/posted/post-graph.tsx
Adds client component PostGraph (default export) that queries yearly publishing activity, computes totals, falls back to deterministic fakeData when needed, and renders a ContributionGraph heatmap with skeleton, blur/overlay for fake data, tooltips, footer totals/badge, and legend.
UI: ContributionGraph library
src/components/ui/kibo-ui/contribution-graph/index.tsx
New ContributionGraph module: types (Activity, Labels, various props), helpers (fillHoles, groupByWeeks, getMonthLabels), context, and components (ContributionGraph, ContributionGraphBlock, ContributionGraphCalendar, ContributionGraphFooter, ContributionGraphTotalCount, ContributionGraphLegend) with date-fns usage, data validation, and SVG rendering.
Server router wiring
src/server/index.ts
Adds posted router to appRouter via dynamic import('./routers/posted-router') (feed line reformatted).
Server: Posted router
src/server/routers/posted-router.ts
New postedRouter (exported) and exported ActivityData type. Endpoints: get_publishing_activity(year?, accountId?), get_publishing_stats(accountId?), get_recent_published(limit?, accountId?). Uses zod validation, date-fns for ranges, Drizzle ORM for DB queries/aggregation, private procedures, and returns structured payloads (activity, totals, recent items).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant P as Posted Page
  participant G as PostGraph (Client)
  participant Q as React Query
  participant S as Server appRouter
  participant R as postedRouter

  U->>P: Navigate to /studio/posted
  P->>G: Render <PostGraph/>
  G->>Q: useQuery posted.get_publishing_activity{year, accountId}
  Q->>S: tRPC request
  S->>R: route to postedRouter.get_publishing_activity
  R->>DB: Query published tweets by user/year (+optional account)
  R-->>S: {activity[], totalCount, year, maxCount, maxLevel}
  S-->>Q: Response
  alt loading/error/empty
    G->>G: Generate deterministic fakeData
    G-->>U: Render heatmap (blur + overlay)
  else real data
    G-->>U: Render heatmap with tooltips, legend, totals
  end
Loading
sequenceDiagram
  autonumber
  participant C as Client (tRPC)
  participant R as postedRouter
  participant DB as Database

  rect rgba(220,240,255,0.35)
  note over C,R: get_publishing_activity(year?, accountId?)
  C->>R: Request
  R->>DB: Aggregate published tweets per day for year (+optional account)
  R->>R: Map counts -> levels (0–4), compute totals
  R-->>C: {activities[], totalCount, year, maxCount, maxLevel}
  end

  rect rgba(220,255,220,0.28)
  note over C,R: get_publishing_stats(accountId?)
  C->>R: Request
  R->>DB: Compute totalPublished, thisMonthPublished, scheduledCount
  R-->>C: {totalPublished, thisMonthPublished, scheduledCount}
  end

  rect rgba(255,240,220,0.28)
  note over C,R: get_recent_published(limit?, accountId?)
  C->>R: Request
  R->>DB: Fetch recent published tweets (limit)
  R-->>C: [{id, content, createdAt, twitterId, accountId}...]
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I hop on calendars, tiny paws in rows,
Painting carrots where the posting grows.
Real or pretend, each square I tend,
New routes and maps down burrows bend.
A whiskered cheer — the heatmap glows. 🥕✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "feat: add publishing graph to posted ui" succinctly and accurately summarizes the primary change—adding a publishing/activity graph to the posted UI. It aligns with the PR objectives and the changeset (PostGraph component, ContributionGraph UI, and server/router additions) and is clear for reviewers scanning history.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95a990d and 2d5794a.

📒 Files selected for processing (2)
  • src/components/posted/post-graph.tsx (1 hunks)
  • src/server/routers/posted-router.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/components/posted/post-graph.tsx
  • src/server/routers/posted-router.ts

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


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: 0

🧹 Nitpick comments (15)
src/components/ui/kibo-ui/contribution-graph/index.tsx (5)

161-166: Fix unsafe typing in padded weeks array.

paddedActivities is declared as Activity[] but actually contains undefined elements. This is unsound and can hide bugs.

Apply this diff:

-  const paddedActivities = [
-    ...(new Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(
-      undefined
-    ) as Activity[]),
-    ...normalizedActivities,
-  ];
+  const paddedActivities: Array<Activity | undefined> = [
+    ...new Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(
+      undefined
+    ),
+    ...normalizedActivities,
+  ];

156-160: Prefer startOfWeek for clarity and fewer edge cases.

Computing the first calendar date via nextDay(...)-subWeeks(1) is harder to read and more error‑prone than startOfWeek.

Apply this diff (and add the import):

 import {
   differenceInCalendarDays,
   eachDayOfInterval,
   formatISO,
   getDay,
   getMonth,
   getYear,
-  nextDay,
+  startOfWeek,
   parseISO,
   subWeeks,
 } from "date-fns";
@@
-  const firstCalendarDate =
-    getDay(firstDate) === weekStart
-      ? firstDate
-      : subWeeks(nextDay(firstDate, weekStart), 1);
+  const firstCalendarDate = startOfWeek(firstDate, { weekStartsOn: weekStart });

184-189: Don’t throw on empty weeks; skip gracefully.

An empty week can occur with unusual input. Throwing breaks rendering when a no‑data week slips in.

Apply this diff:

-      if (!firstActivity) {
-        throw new Error(
-          `Unexpected error: Week ${weekIndex + 1} is empty: [${week}].`
-        );
-      }
+      if (!firstActivity) {
+        return labels;
+      }

116-118: Minor: build the map from the sorted array or rename var.

You sort into sortedActivities but build the calendar map from activities. Not wrong, just inconsistent.

Apply this diff:

-  const calendar = new Map<string, Activity>(
-    activities.map((a) => [a.date, a])
-  );
+  const calendar = new Map<string, Activity>(
+    sortedActivities.map((a) => [a.date, a])
+  );

240-256: Expose server maxLevel to the graph (defensive).

The server also computes maxLevel. Even though both use 4 today, piping it through keeps client/server in sync if this changes later.

You don’t need code changes here; see the suggested change in PostGraph to pass maxLevel down.

src/app/studio/posted/page.tsx (1)

20-20: Optional: lazy‑load PostGraph to trim initial bundle.

The graph is non‑critical; consider next/dynamic with a skeleton fallback.

src/components/posted/post-graph.tsx (4)

66-71: Align ContributionGraph with server totals and maxLevel.

Pass totalCount and maxLevel so the context and legend match server output; otherwise, future server changes could desync the legend or totals.

Apply this diff:

-              <ContributionGraph data={data} blockSize={10} blockMargin={3}>
+              <ContributionGraph
+                data={data}
+                blockSize={10}
+                blockMargin={3}
+                totalCount={totalCount}
+                maxLevel={shouldUseFakeData ? 4 : apiData?.maxLevel ?? 4}
+              >

97-101: Disable interactions when showing fake data.

The graph is blurred but still interactive (tooltips fire). Add pointer-events-none when shouldUseFakeData.

Apply this diff:

-            <div className={cn(shouldUseFakeData && "blur-sm")}>
+            <div className={cn(shouldUseFakeData && "blur-sm pointer-events-none")}>

42-64: Make fake data deterministic per day/year.

Math.random() changes on every mount. Use a cheap hash from the date string for stable demo data (no new deps).

Apply this diff:

-  const fakeData = useMemo(() => {
+  const fakeData = useMemo(() => {
     const maxCount = 20;
     const maxLevel = 4;
     const now = new Date();
     const days = eachDayOfInterval({
       start: startOfYear(now),
       end: endOfYear(now),
     });
 
-    return days.map((date) => {
-      const c = Math.round(
-        Math.random() * maxCount - Math.random() * (0.8 * maxCount)
-      );
+    const hash = (s: string) => {
+      let h = 0;
+      for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
+      return Math.abs(h);
+    };
+
+    return days.map((date) => {
+      const iso = formatISO(date, { representation: "date" });
+      const r = (hash(iso) % 1000) / 1000; // 0..1 stable per day
+      const c = Math.round(r * maxCount * 0.8); // bias lower a bit
       const count = Math.max(0, c);
       const level = Math.ceil((count / maxCount) * maxLevel);
 
       return {
-        date: formatISO(date, { representation: "date" }),
+        date: iso,
         count,
         level
       };
     });
   }, []);

101-117: Use the rect as the Tooltip trigger for better SVG event targeting.

Wrapping <g> works but some tooltip libs expect a focusable element; using the <rect> avoids group-level quirks.

Apply this diff:

-                    <Tooltip>
-                      <TooltipTrigger asChild>
-                        <g>
-                          <ContributionGraphBlock
+                    <Tooltip>
+                      <TooltipTrigger asChild>
+                        <ContributionGraphBlock
                             activity={activity}
                             className={cn(
                               'data-[level="0"]:fill-muted dark:data-[level="0"]:fill-muted',
                               'data-[level="1"]:fill-primary/20 dark:data-[level="1"]:fill-primary/30',
                               'data-[level="2"]:fill-primary/40 dark:data-[level="2"]:fill-primary/50',
                               'data-[level="3"]:fill-primary/60 dark:data-[level="3"]:fill-primary/70',
                               'data-[level="4"]:fill-primary/80 dark:data-[level="4"]:fill-primary/90',
                             )}
                             dayIndex={dayIndex}
                             weekIndex={weekIndex}
                           />
-                        </g>
                       </TooltipTrigger>
src/server/routers/posted-router.ts (5)

36-44: Guard and(...) inputs explicitly; avoid passing undefined.

Depending on Drizzle version, and() may not ignore undefined. Safer to filter falsy entries before composing the predicate.

Apply this diff:

-        .where(
-          and(
-            eq(tweets.userId, user.id),
-            eq(tweets.isPublished, true),
-            gte(tweets.createdAt, startDate),
-            sql`${tweets.createdAt} <= ${endDate}`,
-            accountId ? eq(tweets.accountId, accountId) : undefined
-          )
-        )
+        .where(and(
+          ...[
+            eq(tweets.userId, user.id),
+            eq(tweets.isPublished, true),
+            gte(tweets.createdAt, startDate),
+            sql`${tweets.createdAt} <= ${endDate}`,
+            accountId ? eq(tweets.accountId, accountId) : undefined,
+          ].filter(Boolean) as any[]
+        ))

32-47: Index‑friendly date filtering.

DATE(created_at) prevents index use. Prefer range queries on created_at and group by the date cast only in SELECT/GROUP BY result, not in the WHERE—which you already do. To improve grouping, also cast once with an alias and reuse it.

Apply this diff to reuse the computed date:

-      const dailyTweets = await db
-        .select({
-          date: sql<string>`DATE(${tweets.createdAt})`.as('date'),
-          count: sql<number>`COUNT(*)`.as('count'),
-        })
+      const dayExpr = sql<string>`DATE(${tweets.createdAt})`;
+      const dailyTweets = await db
+        .select({
+          date: dayExpr.as('date'),
+          count: sql<number>`COUNT(*)`.as('count'),
+        })
         .from(tweets)
         .where(
           and(
             eq(tweets.userId, user.id),
             eq(tweets.isPublished, true),
             gte(tweets.createdAt, startDate),
             sql`${tweets.createdAt} <= ${endDate}`,
             accountId ? eq(tweets.accountId, accountId) : undefined
           )
         )
-        .groupBy(sql`DATE(${tweets.createdAt})`)
-        .orderBy(sql`DATE(${tweets.createdAt})`)
+        .groupBy(dayExpr)
+        .orderBy(dayExpr)

53-63: Zero‑count level mapping: clamp once for readability.

Current logic is fine; a small refactor reads cleaner.

Apply this diff:

-        const level = count === 0 ? 0 : Math.min(Math.ceil((count / maxCount) * maxLevel), maxLevel)
+        const level = count === 0 ? 0 : Math.min(maxLevel, Math.ceil((count / maxCount) * maxLevel))

73-81: Return shape: consider echoing weekStart for clients.

Clients can align calendars without guessing. Optional, but helps keep UI/server aligned.

       return c.superjson({
         activity,
         totalCount,
         year,
         maxCount,
         maxLevel,
+        weekStart: 0,
       })

84-137: Stats queries: factor common predicates.

The three stats queries repeat the same and(...) pattern. Extract a const whereBase = [...] and reuse to reduce mistakes.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7376617 and 95a990d.

📒 Files selected for processing (5)
  • src/app/studio/posted/page.tsx (2 hunks)
  • src/components/posted/post-graph.tsx (1 hunks)
  • src/components/ui/kibo-ui/contribution-graph/index.tsx (1 hunks)
  • src/server/index.ts (1 hunks)
  • src/server/routers/posted-router.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,py,go,java,rb}

📄 CodeRabbit inference engine (.cursor/rules/posthog-integration.mdc)

Never hallucinate an API key. Instead, always use the API key populated in the .env file.

Files:

  • src/server/index.ts
  • src/server/routers/posted-router.ts
  • src/app/studio/posted/page.tsx
  • src/components/posted/post-graph.tsx
  • src/components/ui/kibo-ui/contribution-graph/index.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursor/rules/posthog-integration.mdc)

**/*.{ts,tsx,js,jsx}: If using TypeScript, use an enum to store flag names. If using JavaScript, store flag names as strings to an object declared as a constant, to simulate an enum. Use a consistent naming convention for this storage.
enum/const object members should be written UPPERCASE_WITH_UNDERSCORE.
Gate flag-dependent code on a check that verifies the flag's values are valid and expected.
If a custom property for a person or event is at any point referenced in two or more files or two or more callsites in the same file, use an enum or const object, as above in feature flags.

Files:

  • src/server/index.ts
  • src/server/routers/posted-router.ts
  • src/app/studio/posted/page.tsx
  • src/components/posted/post-graph.tsx
  • src/components/ui/kibo-ui/contribution-graph/index.tsx
🧬 Code graph analysis (3)
src/server/routers/posted-router.ts (3)
src/server/jstack.ts (2)
  • j (34-34)
  • privateProcedure (52-52)
src/db/schema/tweet.ts (1)
  • tweets (19-49)
src/db/schema/auth.ts (1)
  • user (3-19)
src/components/posted/post-graph.tsx (4)
src/hooks/account-ctx.tsx (1)
  • useAccount (51-55)
src/lib/client.ts (1)
  • client (8-11)
src/lib/utils.ts (1)
  • cn (6-8)
src/components/ui/kibo-ui/contribution-graph/index.tsx (6)
  • ContributionGraph (240-304)
  • ContributionGraphCalendar (365-420)
  • ContributionGraphBlock (312-350)
  • ContributionGraphFooter (424-435)
  • ContributionGraphTotalCount (444-464)
  • ContributionGraphLegend (473-517)
src/components/ui/kibo-ui/contribution-graph/index.tsx (1)
src/lib/utils.ts (1)
  • cn (6-8)
🔇 Additional comments (3)
src/app/studio/posted/page.tsx (1)

3-3: LGTM: PostGraph import is correct.

src/components/posted/post-graph.tsx (1)

30-40: Future‑proof: server input year bound is 2030.

Your currentYear comes from the system clock; after 2030 the request will fail input validation. Consider clamping to server bounds or surfacing a clear error.

Apply this diff (optional):

-  const currentYear = new Date().getFullYear();
+  const currentYear = Math.min(new Date().getFullYear(), 2030);
src/server/index.ts (1)

45-47: Router wiring looks correct — regenerate client types & verify posted usage compiles

Found client usage at src/components/posted/post-graph.tsx (lines 33–36: client.posted.getPublishingActivity.$get). Regenerate your tRPC client types (and run a full typecheck/build), then start the app to confirm the new posted routes compile and are reachable.

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.

1 participant