From 27539582339c71a84b92ae8f53a2f2d8de3e52ee Mon Sep 17 00:00:00 2001
From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com>
Date: Tue, 10 Mar 2026 12:55:08 +0200
Subject: [PATCH 1/2] fix: recover notification CTR with celebratory copy and
dynamic push headings
Post-#3657 data showed CTR declines across 7 notification types. Root
causes: lost celebratory tone in achievement/milestone types, and bland
push headings replacing the curiosity-inducing "New update" default.
Title changes:
- user_given_top_reader: restore "Great news!" opener (-84.6% without it)
- achievement_unlocked: add "You earned" for personal warmth
- squad_member_joined: add "Say hi!" CTA
- Upvote milestones: geeky tiered copy (dev/gaming references) replacing
dashboard-stat "number. remark" format
Push heading changes:
- Dynamic headings for squad_post_added ("New post in {squad}"),
squad_new_comment and article_new_comment ("{name} commented")
- achievement_unlocked: "Achievement unlocked" -> "Level up!"
- upvote milestones: "Milestone reached" -> "New milestone"
Preserves all improvements from #3657 (source_post_approved +106.5%,
post_bookmark_reminder +23.4%, comment_mention +17.6%).
Made-with: Cursor
---
__tests__/notifications/index.ts | 4 ++-
__tests__/workers/newNotificationV2Mail.ts | 4 +--
src/notifications/generate.ts | 6 ++--
src/onesignal.ts | 32 ++++++++++++++++++----
src/workers/notifications/utils.ts | 26 +++++++++---------
5 files changed, 47 insertions(+), 25 deletions(-)
diff --git a/__tests__/notifications/index.ts b/__tests__/notifications/index.ts
index 54a25a0cf7..188b5405f2 100644
--- a/__tests__/notifications/index.ts
+++ b/__tests__/notifications/index.ts
@@ -1100,7 +1100,9 @@ describe('generateNotification', () => {
expect(actual.notification.targetUrl).toEqual(
'http://localhost:5002/squads/a',
);
- expect(actual.notification.title).toEqual('Tsahi joined A');
+ expect(actual.notification.title).toEqual(
+ 'Tsahi joined A. Say hi!',
+ );
expect(actual.avatars).toEqual([
{
image: 'http://image.com/a',
diff --git a/__tests__/workers/newNotificationV2Mail.ts b/__tests__/workers/newNotificationV2Mail.ts
index d5f39c250d..7c7e1d9904 100644
--- a/__tests__/workers/newNotificationV2Mail.ts
+++ b/__tests__/workers/newNotificationV2Mail.ts
@@ -316,7 +316,7 @@ it('should set parameters for article_upvote_milestone email', async () => {
post_image: 'https://daily.dev/image.jpg',
post_title: 'P1',
upvotes: '50',
- upvote_title: '50 upvotes. Your post is trending 🔥',
+ upvote_title: '50 upvotes! Trending on the feed 🔥',
});
expect(args.transactional_message_id).toEqual('22');
});
@@ -682,7 +682,7 @@ it('should set parameters for comment_upvote_milestone email', async () => {
discussion_link:
'http://localhost:5002/posts/p1?utm_source=notification&utm_medium=email&utm_campaign=comment_upvote_milestone#c-c1',
main_comment: 'parent comment',
- upvote_title: '50 upvotes. Your post is trending 🔥',
+ upvote_title: '50 upvotes! Trending on the feed 🔥',
});
expect(args.transactional_message_id).toEqual('44');
});
diff --git a/src/notifications/generate.ts b/src/notifications/generate.ts
index f882190954..4808e267c8 100644
--- a/src/notifications/generate.ts
+++ b/src/notifications/generate.ts
@@ -107,7 +107,7 @@ export const notificationTitleMap: Record<
ctx: NotificationPostContext &
NotificationSourceContext &
NotificationDoneByContext,
- ) => `${ctx.doneBy.name} joined ${ctx.source.name}`,
+ ) => `${ctx.doneBy.name} joined ${ctx.source.name}. Say hi!`,
squad_new_comment: (ctx: NotificationCommenterContext) =>
`${ctx.commenter.name} commented on your post on ${ctx.source.name}.`,
squad_reply: (ctx: NotificationCommenterContext) =>
@@ -150,7 +150,7 @@ export const notificationTitleMap: Record<
user_given_top_reader: (ctx: NotificationUserTopReaderContext) => {
const keyword =
(ctx.keyword.flags as KeywordFlags)?.title || ctx.keyword.value;
- return `You earned the Top Reader badge in ${keyword}`;
+ return `Great news! You earned the Top Reader badge in ${keyword}`;
},
source_post_approved: (ctx: NotificationPostContext) =>
`Your post in ${ctx.source.name} has been approved and is now live`,
@@ -239,7 +239,7 @@ export const notificationTitleMap: Record<
`Your feedback has been resolved`,
feedback_cancelled: () => feedbackCancelledTitle,
achievement_unlocked: (ctx: NotificationAchievementContext) =>
- `Achievement unlocked! ${ctx.achievementName}`,
+ `Achievement unlocked! You earned ${ctx.achievementName}`,
digest_ready: () => `Your personalized digest is ready`,
};
diff --git a/src/onesignal.ts b/src/onesignal.ts
index d7375aa57d..884a6232f4 100644
--- a/src/onesignal.ts
+++ b/src/onesignal.ts
@@ -25,8 +25,8 @@ const pushHeadingMap: Partial> = {
[NotificationType.SquadReply]: 'New reply',
[NotificationType.CommentMention]: 'You were mentioned',
[NotificationType.PostMention]: 'You were mentioned',
- [NotificationType.ArticleUpvoteMilestone]: 'Milestone reached',
- [NotificationType.CommentUpvoteMilestone]: 'Milestone reached',
+ [NotificationType.ArticleUpvoteMilestone]: 'New milestone',
+ [NotificationType.CommentUpvoteMilestone]: 'New milestone',
[NotificationType.SquadPostAdded]: 'New squad post',
[NotificationType.SquadMemberJoined]: 'New member',
[NotificationType.UserFollow]: 'New follower',
@@ -36,7 +36,7 @@ const pushHeadingMap: Partial> = {
[NotificationType.DigestReady]: 'Digest ready',
[NotificationType.StreakResetRestore]: 'Streak broken',
[NotificationType.UserGiftedPlus]: 'Plus gift',
- [NotificationType.AchievementUnlocked]: 'Achievement unlocked',
+ [NotificationType.AchievementUnlocked]: 'Level up!',
[NotificationType.PollResult]: 'Poll results',
[NotificationType.PollResultAuthor]: 'Poll results',
[NotificationType.FeedbackResolved]: 'Feedback update',
@@ -75,8 +75,28 @@ const pushHeadingMap: Partial> = {
[NotificationType.SquadSubscribeToNotification]: 'Squad notifications',
};
-const getPushHeading = (type: string): string =>
- pushHeadingMap[type as NotificationType] ?? 'daily.dev';
+const pushHeadingFnMap: Partial<
+ Record string>
+> = {
+ [NotificationType.SquadPostAdded]: (title) => {
+ const match = title.match(/([^<]+)<\/b>[^<]*([^<]+)<\/b>/);
+ return match ? `New post in ${match[2]}` : 'New squad post';
+ },
+ [NotificationType.SquadNewComment]: (title) => {
+ const match = title.match(/([^<]+)<\/b>/);
+ return match ? `${match[1]} commented` : 'New comment';
+ },
+ [NotificationType.ArticleNewComment]: (title) => {
+ const match = title.match(/([^<]+)<\/b>/);
+ return match ? `${match[1]} commented` : 'New comment';
+ },
+};
+
+const getPushHeading = (type: string, title?: string): string => {
+ const fn = pushHeadingFnMap[type as NotificationType];
+ if (fn && title) return fn(title);
+ return pushHeadingMap[type as NotificationType] ?? 'daily.dev';
+};
type PushOpts = { increaseBadge?: boolean };
@@ -121,7 +141,7 @@ export async function sendPushNotification(
const push = createPush(userIds, targetUrl, type, { increaseBadge: true });
push.contents = { en: basicHtmlStrip(title) };
- push.headings = { en: getPushHeading(type) };
+ push.headings = { en: getPushHeading(type, title) };
push.data = { notificationId: id };
if (avatar) {
push.chrome_web_icon = mapCloudinaryUrl(avatar.image);
diff --git a/src/workers/notifications/utils.ts b/src/workers/notifications/utils.ts
index 6d1a0e006d..4d93fc0755 100644
--- a/src/workers/notifications/utils.ts
+++ b/src/workers/notifications/utils.ts
@@ -246,20 +246,20 @@ export async function articleNewCommentHandler(
}
export const UPVOTE_TITLES = {
- 1: '1 upvote. Your first! 🎉',
- 3: '3 upvotes. Off to a good start ✨',
- 5: '5 upvotes. People are reading 👀',
- 10: '10 upvotes. Double digits 🙌',
- 20: '20 upvotes. People are noticing 🥳',
- 50: '50 upvotes. Your post is trending 🔥',
- 100: '100 upvotes. Triple digits ⚡️',
- 200: '200 upvotes. This one took off 🚀',
- 500: '500 upvotes. Going viral 📈',
- 1000: '1,000 upvotes. Legendary 💎',
- 2000: '2,000 upvotes. Top of the charts 💥',
- 5000: '5,000 upvotes. One for the books 🏆',
+ 1: '1 upvote! Off to a good start 🎉',
+ 3: '3 upvotes. No bugs, just vibes ✨',
+ 5: '5 upvotes! Gaining traction 👀',
+ 10: '10 upvotes! New high score 🙌',
+ 20: '20 upvotes! Level up 🥳',
+ 50: '50 upvotes! Trending on the feed 🔥',
+ 100: '100 upvotes! Critical hit ⚡️',
+ 200: '200 upvotes! This one took off 🚀',
+ 500: '500 upvotes! Going viral 📈',
+ 1000: '1,000 upvotes! Legendary unlocked 💎',
+ 2000: '2,000 upvotes! Mythic tier 💥',
+ 5000: '5,000 upvotes! Hall of fame 🏆',
10000:
- '10,000 upvotes. History made 👑',
+ '10,000 upvotes! GOAT 👑',
};
export const UPVOTE_MILESTONES = Object.keys(UPVOTE_TITLES);
From 60892ef29a26dbecf4d8ad7d99e06ce2c1aee733 Mon Sep 17 00:00:00 2001
From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com>
Date: Tue, 10 Mar 2026 13:07:15 +0200
Subject: [PATCH 2/2] fix: consistent punctuation in upvote milestone copy
Change "3 upvotes." to "3 upvotes!" to match all other milestones.
Made-with: Cursor
---
src/workers/notifications/utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/workers/notifications/utils.ts b/src/workers/notifications/utils.ts
index 4d93fc0755..9f8925cf9d 100644
--- a/src/workers/notifications/utils.ts
+++ b/src/workers/notifications/utils.ts
@@ -247,7 +247,7 @@ export async function articleNewCommentHandler(
export const UPVOTE_TITLES = {
1: '1 upvote! Off to a good start 🎉',
- 3: '3 upvotes. No bugs, just vibes ✨',
+ 3: '3 upvotes! No bugs, just vibes ✨',
5: '5 upvotes! Gaining traction 👀',
10: '10 upvotes! New high score 🙌',
20: '20 upvotes! Level up 🥳',