From 57700be18cb25686f1ebee55692c0b7727fa5405 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 19 May 2026 11:08:37 +0300 Subject: [PATCH 01/93] feat(webapp): your brief homepage mockup behind feature flag Adds a new /brief route as a community-first, hybrid-data daily briefing experience: hero greeting + streak, lead story with a community quote pull, inline-expandable reads, 2x2 topical digests, quick-hit one-liners, a two-state closing card, and a bridge into a real useFeed slice of the explore feed. Gated behind a new featureBriefingHome flag and surfaced via a "Your brief" sidebar entry. Co-authored-by: Cursor --- .../sidebar/sections/MainSection.tsx | 20 + packages/shared/src/lib/featureManagement.ts | 1 + .../components/briefingHome/BriefFooter.tsx | 33 ++ .../briefingHome/BriefingHomePage.tsx | 140 +++++ .../components/briefingHome/ClosingCard.tsx | 107 ++++ .../components/briefingHome/ExploreBridge.tsx | 189 +++++++ .../webapp/components/briefingHome/Hero.tsx | 105 ++++ .../components/briefingHome/LeadStory.tsx | 18 + .../components/briefingHome/QuickHits.tsx | 86 +++ .../briefingHome/SectionEyebrow.tsx | 44 ++ .../components/briefingHome/ShareBrief.tsx | 164 ++++++ .../components/briefingHome/StoryDetail.tsx | 174 ++++++ .../components/briefingHome/StoryRow.tsx | 234 ++++++++ .../briefingHome/TopicDigestCard.tsx | 125 +++++ .../briefingHome/TopicDigestGrid.tsx | 27 + .../components/briefingHome/briefMockData.ts | 507 ++++++++++++++++++ .../webapp/components/briefingHome/copy.ts | 54 ++ .../briefingHome/hooks/useBriefItems.ts | 4 + .../briefingHome/hooks/useReadTracker.ts | 61 +++ .../webapp/components/briefingHome/types.ts | 86 +++ packages/webapp/pages/brief.tsx | 55 ++ 21 files changed, 2234 insertions(+) create mode 100644 packages/webapp/components/briefingHome/BriefFooter.tsx create mode 100644 packages/webapp/components/briefingHome/BriefingHomePage.tsx create mode 100644 packages/webapp/components/briefingHome/ClosingCard.tsx create mode 100644 packages/webapp/components/briefingHome/ExploreBridge.tsx create mode 100644 packages/webapp/components/briefingHome/Hero.tsx create mode 100644 packages/webapp/components/briefingHome/LeadStory.tsx create mode 100644 packages/webapp/components/briefingHome/QuickHits.tsx create mode 100644 packages/webapp/components/briefingHome/SectionEyebrow.tsx create mode 100644 packages/webapp/components/briefingHome/ShareBrief.tsx create mode 100644 packages/webapp/components/briefingHome/StoryDetail.tsx create mode 100644 packages/webapp/components/briefingHome/StoryRow.tsx create mode 100644 packages/webapp/components/briefingHome/TopicDigestCard.tsx create mode 100644 packages/webapp/components/briefingHome/TopicDigestGrid.tsx create mode 100644 packages/webapp/components/briefingHome/briefMockData.ts create mode 100644 packages/webapp/components/briefingHome/copy.ts create mode 100644 packages/webapp/components/briefingHome/hooks/useBriefItems.ts create mode 100644 packages/webapp/components/briefingHome/hooks/useReadTracker.ts create mode 100644 packages/webapp/components/briefingHome/types.ts create mode 100644 packages/webapp/pages/brief.tsx diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index 54b2f899a69..f06f6dc2c38 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -4,6 +4,7 @@ import { Section } from '../Section'; import type { SidebarMenuItem } from '../common'; import { ListIcon } from '../common'; import { + BriefIcon, DevPlusIcon, EyeIcon, HomeIcon, @@ -27,6 +28,7 @@ import { SharedFeedPage } from '../../utilities'; import { isExtension } from '../../../lib/func'; import { useConditionalFeature } from '../../../hooks'; import { + featureBriefingHome, featurePlusApiLanding, featureYearInReview, } from '../../../lib/featureManagement'; @@ -52,6 +54,10 @@ export const MainSection = ({ feature: featureYearInReview, shouldEvaluate: isLoggedIn, }); + const { value: showBriefingHome } = useConditionalFeature({ + feature: featureBriefingHome, + shouldEvaluate: isLoggedIn, + }); const { data: questDashboard } = useQuestDashboard(); const claimableMilestoneCount = useMemo( () => @@ -133,6 +139,18 @@ export const MainSection = ({ } : undefined; + const briefingHome = showBriefingHome + ? { + icon: (active: boolean) => ( + } /> + ), + title: 'Your brief', + path: '/brief', + isForcedLink: true, + requiresLogin: true, + } + : undefined; + const yearInReview = showYearInReview ? { icon: () => } />, @@ -147,6 +165,7 @@ export const MainSection = ({ return ( [ myFeed, + briefingHome, { title: 'Following', // this path can be opened on extension so it purposly @@ -197,6 +216,7 @@ export const MainSection = ({ isLoggedIn, isPlus, onNavTabClick, + showBriefingHome, showYearInReview, user, ]); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 430d88188ca..c30841d8244 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -136,6 +136,7 @@ export const briefFeedEntrypointPage = new Feature( ); export const briefUIFeature = new Feature('brief_ui', isDevelopment); +export const featureBriefingHome = new Feature('briefing_home', isDevelopment); export const boostSettingsFeature = new Feature('boost_settings', { min: 1000, max: 100000, diff --git a/packages/webapp/components/briefingHome/BriefFooter.tsx b/packages/webapp/components/briefingHome/BriefFooter.tsx new file mode 100644 index 00000000000..df552a29ab7 --- /dev/null +++ b/packages/webapp/components/briefingHome/BriefFooter.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +const dayOfYear = (): number => { + const now = new Date(); + const start = new Date(now.getFullYear(), 0, 0); + const diff = now.getTime() - start.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); +}; + +export const BriefFooter = (): ReactElement => ( +
+ + Edition #{dayOfYear()} + + + daily.dev · brief + +
+); diff --git a/packages/webapp/components/briefingHome/BriefingHomePage.tsx b/packages/webapp/components/briefingHome/BriefingHomePage.tsx new file mode 100644 index 00000000000..42d883e50e0 --- /dev/null +++ b/packages/webapp/components/briefingHome/BriefingHomePage.tsx @@ -0,0 +1,140 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { Hero } from './Hero'; +import { LeadStory } from './LeadStory'; +import { StoryRow } from './StoryRow'; +import { TopicDigestGrid } from './TopicDigestGrid'; +import { QuickHits } from './QuickHits'; +import { ClosingCard } from './ClosingCard'; +import { ExploreBridge } from './ExploreBridge'; +import { BriefFooter } from './BriefFooter'; +import { SectionEyebrow } from './SectionEyebrow'; +import { useBriefItems } from './hooks/useBriefItems'; +import { useReadTracker } from './hooks/useReadTracker'; +import { briefCopy } from './copy'; +import type { StoryItem, TopicDigest, QuickHit } from './types'; + +const wordsIn = (text: string): number => + text.split(/\s+/).filter(Boolean).length; + +const estimateReadMinutes = ( + lead: StoryItem, + reads: StoryItem[], + topics: TopicDigest[], + quickHits: QuickHit[], +): number => { + const stories = [lead, ...reads]; + const storyWords = stories.reduce( + (sum, s) => + sum + + wordsIn(s.summary) + + s.highlightedComments + .slice(0, 2) + .reduce((acc, c) => acc + wordsIn(c.content), 0), + 0, + ); + const topicWords = topics.reduce( + (sum, t) => + sum + wordsIn(t.tldr) + wordsIn(t.content.replace(/<[^>]+>/g, ' ')) * 0.3, + 0, + ); + const quickWords = quickHits.reduce((sum, q) => sum + wordsIn(q.title), 0); + return Math.max(3, Math.round((storyWords + topicWords + quickWords) / 220)); +}; + +export const BriefingHomePage = (): ReactElement => { + const brief = useBriefItems(); + const { readSet, markRead } = useReadTracker(); + + const allStoryIds = useMemo( + () => [brief.lead.id, ...brief.reads.map((s) => s.id)], + [brief], + ); + const totalStories = + 1 + brief.reads.length + brief.topics.length + brief.quickHits.length; + const readMinutes = useMemo( + () => + estimateReadMinutes( + brief.lead, + brief.reads, + brief.topics, + brief.quickHits, + ), + [brief], + ); + const savedMinutes = useMemo(() => { + const stories = [brief.lead, ...brief.reads]; + return stories.reduce( + (sum, s) => sum + s.posts.length * 3 + Math.round(s.totalComments * 0.2), + 0, + ); + }, [brief]); + + const allReadCount = useMemo( + () => + [ + ...allStoryIds, + ...brief.topics.map((t) => t.id), + ...brief.quickHits.map((q) => q.id), + ].filter((id) => readSet.has(id)).length, + [allStoryIds, brief, readSet], + ); + const isComplete = allReadCount === totalStories; + + return ( +
+ + + + markRead(brief.lead.id)} + /> + + +
    + {brief.reads.map((story) => ( +
  • + markRead(story.id)} + /> +
  • + ))} +
+ + + + + + + + + + + +
+ ); +}; diff --git a/packages/webapp/components/briefingHome/ClosingCard.tsx b/packages/webapp/components/briefingHome/ClosingCard.tsx new file mode 100644 index 00000000000..7dab1526233 --- /dev/null +++ b/packages/webapp/components/briefingHome/ClosingCard.tsx @@ -0,0 +1,107 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ArrowIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { ShareBrief } from './ShareBrief'; +import { briefCopy } from './copy'; + +interface ClosingCardProps { + totalStories: number; + readMinutes: number; + savedMinutes: number; + progress: number; + isComplete: boolean; +} + +export const ClosingCard = ({ + totalStories, + readMinutes, + savedMinutes, + progress, + isComplete, +}: ClosingCardProps): ReactElement => ( +
+ {isComplete ? ( + + ) : null} +
+ {isComplete ? ( + + + + ) : null} + + {isComplete ? 'Brief complete' : `${progress} of ${totalStories} read`} + + + {isComplete + ? briefCopy.closingTitleDone + : briefCopy.closingTitleProgress} + + + {isComplete + ? briefCopy.closingSubDone(totalStories, readMinutes, savedMinutes) + : briefCopy.closingSubProgress(totalStories - progress)} + + {isComplete ? ( + + {briefCopy.closingPivot} + + ) : null} +
+ + + + +
+
+
+); diff --git a/packages/webapp/components/briefingHome/ExploreBridge.tsx b/packages/webapp/components/briefingHome/ExploreBridge.tsx new file mode 100644 index 00000000000..c59cada9ab8 --- /dev/null +++ b/packages/webapp/components/briefingHome/ExploreBridge.tsx @@ -0,0 +1,189 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ArrowIcon, + DiscussIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import useFeed from '@dailydotdev/shared/src/hooks/useFeed'; +import { + ANONYMOUS_FEED_QUERY, + FEED_V2_QUERY, +} from '@dailydotdev/shared/src/graphql/feed'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { FeedItemType } from '@dailydotdev/shared/src/components/cards/common/common'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { briefCopy } from './copy'; + +const PAGE_SIZE = 8; + +const TeaserCard = ({ post }: { post: Post }): ReactElement => ( + + + {post.image ? ( +
+ +
+ ) : ( +
+ )} + + {post.source?.name ?? 'post'} + + + {post.title} + +
+ + + + {post.numUpvotes ?? 0} + + + + + + {post.numComments ?? 0} + + +
+
+ +); + +const TeaserSkeleton = (): ReactElement => ( +
+
+
+
+
+
+); + +export const ExploreBridge = (): ReactElement => { + const { user } = useAuthContext(); + const feedQueryKey = useMemo( + () => generateQueryKey(RequestKey.Feeds, user, 'briefing-home-bridge'), + [user], + ); + + const { items, isLoading, isFetching } = useFeed( + feedQueryKey, + PAGE_SIZE, + { adStart: Number.POSITIVE_INFINITY }, + PAGE_SIZE, + { + query: user ? FEED_V2_QUERY : ANONYMOUS_FEED_QUERY, + settings: {}, + variables: {}, + }, + ); + + const posts = useMemo( + () => + items + .filter((it) => it.type === FeedItemType.Post) + .slice(0, PAGE_SIZE) + .map((it) => (it.type === FeedItemType.Post ? it.post : null)) + .filter((p): p is Post => p !== null), + [items], + ); + + const showSkeleton = (isLoading || isFetching) && posts.length === 0; + + return ( +
+
+ + {briefCopy.bridgeEyebrow} + + + {briefCopy.bridgeTitle} + + + {briefCopy.bridgeSub} + +
+ +
+ {showSkeleton + ? Array.from({ length: PAGE_SIZE }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : posts.map((post) => )} +
+ +
+ + + +
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/Hero.tsx b/packages/webapp/components/briefingHome/Hero.tsx new file mode 100644 index 00000000000..91cf8f92463 --- /dev/null +++ b/packages/webapp/components/briefingHome/Hero.tsx @@ -0,0 +1,105 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useReadingStreak } from '@dailydotdev/shared/src/hooks/streaks/useReadingStreak'; +import { TimerIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { briefCopy } from './copy'; + +interface HeroProps { + storyCount: number; + readMinutes: number; +} + +const greetingFor = (name: string): string => { + const hour = new Date().getHours(); + if (hour < 12) { + return briefCopy.greeting.morning(name); + } + if (hour < 18) { + return briefCopy.greeting.afternoon(name); + } + return briefCopy.greeting.evening(name); +}; + +const formatDate = (): string => + new Date().toLocaleDateString(undefined, { + weekday: 'long', + month: 'short', + day: 'numeric', + }); + +export const Hero = ({ storyCount, readMinutes }: HeroProps): ReactElement => { + const { user } = useAuthContext(); + const { streak } = useReadingStreak(); + const displayName = useMemo( + () => user?.name?.split(' ')[0] || user?.username || 'there', + [user], + ); + + return ( +
+ + {formatDate()} + + + {greetingFor(displayName)} + + + {briefCopy.heroFrame(storyCount, readMinutes)} + +
+ + + + {briefCopy.readPill(readMinutes)} + + + {streak?.current ? ( + + + + + {streak.current} + + {briefCopy.streakSuffix} + + + ) : null} +
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/LeadStory.tsx b/packages/webapp/components/briefingHome/LeadStory.tsx new file mode 100644 index 00000000000..5cdee4e8654 --- /dev/null +++ b/packages/webapp/components/briefingHome/LeadStory.tsx @@ -0,0 +1,18 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { StoryRow } from './StoryRow'; +import type { StoryItem } from './types'; + +interface LeadStoryProps { + story: StoryItem; + isRead: boolean; + onRead: () => void; +} + +export const LeadStory = ({ + story, + isRead, + onRead, +}: LeadStoryProps): ReactElement => ( + +); diff --git a/packages/webapp/components/briefingHome/QuickHits.tsx b/packages/webapp/components/briefingHome/QuickHits.tsx new file mode 100644 index 00000000000..afb979be2f8 --- /dev/null +++ b/packages/webapp/components/briefingHome/QuickHits.tsx @@ -0,0 +1,86 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + UpvoteIcon, + DiscussIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { QuickHit } from './types'; + +interface QuickHitsProps { + quickHits: QuickHit[]; + readSet: Set; + onRead: (id: string) => void; +} + +export const QuickHits = ({ + quickHits, + readSet, + onRead, +}: QuickHitsProps): ReactElement => ( + +); diff --git a/packages/webapp/components/briefingHome/SectionEyebrow.tsx b/packages/webapp/components/briefingHome/SectionEyebrow.tsx new file mode 100644 index 00000000000..c36a76ebdeb --- /dev/null +++ b/packages/webapp/components/briefingHome/SectionEyebrow.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; + +interface SectionEyebrowProps { + label: string; + className?: string; + trailing?: ReactNode; +} + +export const SectionEyebrow = ({ + label, + className, + trailing, +}: SectionEyebrowProps): ReactElement => ( +
+ + {label} + + {trailing ? ( + + {trailing} + + ) : null} +
+); diff --git a/packages/webapp/components/briefingHome/ShareBrief.tsx b/packages/webapp/components/briefingHome/ShareBrief.tsx new file mode 100644 index 00000000000..36171ddc915 --- /dev/null +++ b/packages/webapp/components/briefingHome/ShareBrief.tsx @@ -0,0 +1,164 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + ShareIcon, + LinkIcon, + MailIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { briefCopy } from './copy'; + +interface ShareBriefProps { + variant?: ButtonVariant; + label?: string; +} + +const SHARE_URL = 'https://app.daily.dev/brief'; +const SHARE_TITLE = "Today's daily.dev brief"; + +export const ShareBrief = ({ + variant = ButtonVariant.Primary, + label, +}: ShareBriefProps): ReactElement => { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) { + return undefined; + } + const handler = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(SHARE_URL); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + // clipboard blocked; fall through silently + } + }, []); + + const handleEmail = useCallback(() => { + const subject = encodeURIComponent(SHARE_TITLE); + const body = encodeURIComponent( + `Today's brief caught me up on the dev world in 3 min: ${SHARE_URL}`, + ); + window.location.href = `mailto:?subject=${subject}&body=${body}`; + setOpen(false); + }, []); + + const handleSystem = useCallback(async () => { + type ShareCapable = Navigator & { + share?: (data: ShareData) => Promise; + }; + const nav = navigator as ShareCapable; + if (nav.share) { + try { + await nav.share({ title: SHARE_TITLE, url: SHARE_URL }); + } catch { + // share dismissed; no-op + } + } else { + await handleCopy(); + } + setOpen(false); + }, [handleCopy]); + + return ( +
+ +
+ + {briefCopy.shareHead} + + + + + + {briefCopy.shareFooter} + +
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/StoryDetail.tsx b/packages/webapp/components/briefingHome/StoryDetail.tsx new file mode 100644 index 00000000000..0439bdf133f --- /dev/null +++ b/packages/webapp/components/briefingHome/StoryDetail.tsx @@ -0,0 +1,174 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + UpvoteIcon, + DiscussIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + ProfilePicture, + ProfileImageSize, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import type { StoryItem } from './types'; +import { briefCopy } from './copy'; + +const stripMd = (s: string): string => + s + .replace(/!\[[^\]]*\]\([^)]+\)/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_]{1,2}([^*_\n]+)[*_]{1,2}/g, '$1'); + +interface StoryDetailProps { + story: StoryItem; +} + +export const StoryDetail = ({ story }: StoryDetailProps): ReactElement => { + const comments = story.highlightedComments.slice(0, 3); + return ( +
+
+ + {briefCopy.tldrTag} + + + {story.summary} + +
+ + {comments.length > 0 ? ( +
+ + {briefCopy.conversationLabel} + +
    + {comments.map((c) => ( +
  • + +
    +
    + + @{c.username} + + + + + {c.upvotes} + + +
    + + {stripMd(c.content)} + +
    +
  • + ))} +
+
+ ) : null} + +
+ + {briefCopy.threadLabel(story.posts.length)} + + +
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/StoryRow.tsx b/packages/webapp/components/briefingHome/StoryRow.tsx new file mode 100644 index 00000000000..2acd989fa73 --- /dev/null +++ b/packages/webapp/components/briefingHome/StoryRow.tsx @@ -0,0 +1,234 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + ArrowIcon, + DiscussIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + ProfilePicture, + ProfileImageSize, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { StoryDetail } from './StoryDetail'; +import type { StoryItem } from './types'; +import { briefCopy } from './copy'; + +interface StoryRowProps { + story: StoryItem; + isRead: boolean; + onRead: () => void; + isLead?: boolean; +} + +const startTransition = (update: () => void): void => { + if (typeof document === 'undefined') { + update(); + return; + } + const api = ( + document as unknown as { + startViewTransition?: (cb: () => void) => void; + } + ).startViewTransition; + if (api) { + api.call(document, update); + return; + } + update(); +}; + +const SourceStack = ({ + story, + size = 'sm', +}: { + story: StoryItem; + size?: 'sm' | 'md'; +}): ReactElement => { + const shown = story.sources.slice(0, 4); + const remaining = story.sources.length - shown.length; + const dimension = size === 'md' ? 'size-7' : 'size-5'; + const overlap = size === 'md' ? '-ml-2' : '-ml-1.5'; + return ( + + ); +}; + +const QuotePull = ({ story }: { story: StoryItem }): ReactElement | null => { + const top = story.highlightedComments[0]; + if (!top) { + return null; + } + const clean = top.content + .replace(/!\[[^\]]*\]\([^)]+\)/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_]{1,2}([^*_\n]+)[*_]{1,2}/g, '$1') + .trim(); + const short = clean.length > 220 ? `${clean.slice(0, 217)}…` : clean; + return ( +
+ +
+ + “{short}” + + + @{top.username} ·{' '} + + {top.upvotes} + + +
+
+ ); +}; + +export const StoryRow = ({ + story, + isRead, + onRead, + isLead, +}: StoryRowProps): ReactElement => { + const [open, setOpen] = useState(false); + + const toggle = useCallback(() => { + startTransition(() => { + setOpen((prev) => { + const next = !prev; + if (next) { + onRead(); + } + return next; + }); + }); + }, [onRead]); + + return ( +
+ +
+
+ +
+
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/TopicDigestCard.tsx b/packages/webapp/components/briefingHome/TopicDigestCard.tsx new file mode 100644 index 00000000000..bc0a9b165e0 --- /dev/null +++ b/packages/webapp/components/briefingHome/TopicDigestCard.tsx @@ -0,0 +1,125 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { TOPIC_TOKEN, type TopicDigest } from './types'; +import { briefCopy } from './copy'; + +interface TopicDigestCardProps { + topic: TopicDigest; + isRead: boolean; + onRead: () => void; +} + +export const TopicDigestCard = ({ + topic, + isRead, + onRead, +}: TopicDigestCardProps): ReactElement => { + const [open, setOpen] = useState(false); + const toggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + if (next) { + onRead(); + } + return next; + }); + }, [onRead]); + + const accentClass = TOPIC_TOKEN[topic.topic]; + + return ( +
+ + + + {topic.tldr} + + +
+
+
+
+
+ +
+ +
+
+ ); +}; diff --git a/packages/webapp/components/briefingHome/TopicDigestGrid.tsx b/packages/webapp/components/briefingHome/TopicDigestGrid.tsx new file mode 100644 index 00000000000..34c0dcdc9df --- /dev/null +++ b/packages/webapp/components/briefingHome/TopicDigestGrid.tsx @@ -0,0 +1,27 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { TopicDigestCard } from './TopicDigestCard'; +import type { TopicDigest } from './types'; + +interface TopicDigestGridProps { + topics: TopicDigest[]; + readSet: Set; + onRead: (id: string) => void; +} + +export const TopicDigestGrid = ({ + topics, + readSet, + onRead, +}: TopicDigestGridProps): ReactElement => ( +
+ {topics.map((topic) => ( + onRead(topic.id)} + /> + ))} +
+); diff --git a/packages/webapp/components/briefingHome/briefMockData.ts b/packages/webapp/components/briefingHome/briefMockData.ts new file mode 100644 index 00000000000..20382d24520 --- /dev/null +++ b/packages/webapp/components/briefingHome/briefMockData.ts @@ -0,0 +1,507 @@ +import type { BriefData } from './types'; + +export const briefMockData: BriefData = { + lead: { + id: 'story-0', + kind: 'story', + title: '"Agentic coding is a trap" and the mass freakout that followed', + summary: + "A viral essay coined the term \"cognitive debt\" for what happens when you lean on AI coding tools: you lose the skills you'd need to catch the AI's mistakes. That hit a nerve. Theo agreed and added that the supervision paradox makes it worse. CodeHead pulled the numbers (92% of US devs use AI daily, junior job postings down 67%). But not everyone bought the doom framing. Ibrahim Diallo pointed out the real disaster in the Cursor/database incident was a production-wipe endpoint that shouldn't have existed, AI or not. Ayende and others drew parallels to outsourcing and marathon pacing, arguing the hangover from moving too fast hasn't landed yet. If you've been wondering whether your AI workflow is building something or borrowing against your future self, this is the thread to read.", + posts: [ + { + id: 'HMCS0qfrU', + title: 'We all fell for it…', + image: 'https://i.ytimg.com/vi/lNVa33qUzZ8/sddefault.jpg', + upvotes: 100, + comments: 13, + }, + { + id: 'kpHyDvvns', + title: 'Learning to code, 1990s vs 2026', + image: null, + upvotes: 90, + comments: 14, + }, + { + id: 'kkOfHDZzw', + title: 'AI didn’t delete your database, you did', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/3e01b6b2c60895dacae4bfaa2e68c5b0?_a=AQAEuop', + upvotes: 86, + comments: 17, + }, + { + id: 'PgwQ8mQNf', + title: 'We Need To Talk About The Vibe Coding Pandemic...', + image: 'https://i.ytimg.com/vi/z4QMrqQhv34/sddefault.jpg', + upvotes: 93, + comments: 5, + }, + { + id: 'IJOv3T1ee', + title: 'What We Lost the Last Time Code Got Cheap', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/f18874be438cbba4d97a4d95c8329ecc?_a=AQAEuop', + upvotes: 73, + comments: 7, + }, + ], + totalUpvotes: 517, + totalComments: 56, + sources: [ + { + sourceId: 't3dotgg', + sourceName: 'Theo - t3․gg', + sourceImage: + 'https://daily-now-res.cloudinary.com/image/upload/s--UmX7IyU3--/f_auto/v1704628081/logos/t3dotgg.jpg', + }, + { + sourceId: 'ayende', + sourceName: 'Ayende @ Rahien', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/a1a9a090200940429fbdcbdeb13b46d3', + }, + { + sourceId: 'idiallo', + sourceName: 'Ibrahim Diallo', + sourceImage: + 'https://media.daily.dev/image/upload/s--UBuPzW64--/f_auto,q_auto/v1774960037/logos/idiallo?_a=BAMAMiWQ0', + }, + { + sourceId: 'codehead', + sourceName: 'CodeHead', + sourceImage: + 'https://media.daily.dev/image/upload/s--ywzrIEkA--/f_auto/v1745508579/logos/codehead', + }, + { + sourceId: 'hn', + sourceName: 'Hacker News', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/hn', + }, + ], + highlightedComments: [ + { + username: 'confidentcoding', + userImage: + 'https://media.daily.dev/image/upload/s--OGZu5DEc--/f_auto/v1772569630/avatars/avatar_umWZ9aQAng34qk5aaJl2q?_a=BAMAMiiu0', + content: + "This is actually my article which Theo is covering! I'm very humbled that it has received the attention it has, and that he took the time to make a reaction video about it.\n\n[https://app.daily.dev/posts/agentic-coding-is-a-trap-recently-featured-on-hackernews-and-fireship--h9ys4rtgp](https://app.daily.dev/posts/agentic-coding-is-a-trap-recently-featured-on-hackernews-and-fireship--h9ys4rtgp)", + upvotes: 14, + }, + { + username: 'mrshevcoding', + userImage: 'https://avatars.githubusercontent.com/u/190949105?v=4', + content: + "Yikes, I'm scared for the time where we need more senior devs and their are no junior devs because guessed what, no one trained them!", + upvotes: 13, + }, + { + username: 'brianmatthews', + userImage: + 'https://lh3.googleusercontent.com/a/ACg8ocJNZ6I1EE-h3i6W-iKwP8DbrXrW4YwWoiOfD8PEc2mopvzbPA=s96-c', + content: + 'I mean they did have rules in place as Antrhopic documents is the way to prevent risky actions. I guess accountability is missing, from Antrhopic as to why their documentation is ignored by their product.', + upvotes: 12, + }, + ], + }, + reads: [ + { + id: 'story-1', + kind: 'story', + title: 'PHP is having an existential moment', + summary: + "Three unrelated posts, same underlying anxiety. Stitcher (ex-JetBrains) argues PHP's real problem isn't the language, it's that the PHP Foundation won't invest in marketing and a modern web presence. The data backs him up: a Perforce report found only 8% of PHP developers have less than five years of experience. The pipeline is drying up while the installed base ages. And in a quieter but symbolically loaded move, PHP finally dropped its decades-old custom license for BSD three-clause, killing off a GPL incompatibility headache that annoyed packagers for years. None of these authors coordinated. They just all felt the same thing at the same time.", + posts: [ + { + id: '9AEfIdPos', + title: "PHP's biggest problem", + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/8ae75ade6d9e7d6c438d57caf3bbc736?_a=AQAEuop', + upvotes: 128, + comments: 8, + }, + { + id: 'veTKlkvqO', + title: + 'PHP powers most of the web, so why aren’t new developers learning it?', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/c4b4559446e9b4f9ae335a88ffe02785?_a=AQAEuop', + upvotes: 66, + comments: 8, + }, + { + id: 'U8GNSJq2t', + title: 'PHP retires its custom license in favor of BSD three-clause', + image: null, + upvotes: 31, + comments: 0, + }, + ], + totalUpvotes: 225, + totalComments: 16, + sources: [ + { + sourceId: 'stitcher', + sourceName: 'stitcher.io', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/83a32fc89f9943f38951a2b360c0d3ec', + }, + { + sourceId: 'allthingsopen', + sourceName: 'All Things Open', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/s--dgFP6zAp--/f_auto/v1720885901/logos/allthingsopen', + }, + { + sourceId: 'collections', + sourceName: 'Collections', + sourceImage: + 'https://daily-now-res.cloudinary.com/image/upload/s--iK6zGJCz--/f_auto,t_logo/v1698841319/logos/collections.jpg', + }, + ], + highlightedComments: [ + { + username: 'axmad', + userImage: 'https://avatars.githubusercontent.com/u/74871945?v=4', + content: + "In France at least, it's almost the opposite problem there are actually more PHP juniors on the job market than open positions. Post-COVID bootcamps produced a lot of junior devs, but the market contracted fast and many of them are still struggling to land their first role.\n\nThe issue is that most companies still running PHP have legacy projects that require experienced profiles. They can't afford to onboard juniors without solid mentoring, and many don't have the resources to do it.\n\nSo you end up with a weird situation: too many juniors, a shortage of seniors, and seniors retiring. The gap is real and growing.", + upvotes: 15, + }, + { + username: 'jensroland', + userImage: 'https://avatars.githubusercontent.com/u/210009?v=4', + content: + "Honestly if they added support for 'dollarless' variable naming, that would go a long way too. I personally don't mind them, but those dollar prefixes seem to trigger PTSD in some developers, plus they are such an easy target for the language's detractors to point their fingers at. Removing them would make PHP look like the modern language it actually is.", + upvotes: 7, + }, + ], + }, + { + id: 'story-3', + kind: 'story', + title: + 'Postgres is getting seriously fast and two companies found the same trick', + summary: + "Databricks and Neon both published deep dives on the same Postgres bottleneck in the same week, independently. The villain is Full Page Writes: every first write after a checkpoint logs an entire 8KB page to WAL as insurance against torn pages. Both companies eliminate FPW by separating compute from storage, where torn pages can't happen. Databricks reports 5x throughput gains. Neon had to solve an extra problem (unbounded delta chains in their storage layer) but got there too. Then a Lobsters post threw cold water on a related topic: using Postgres as a job queue works fine until it doesn't, and when it breaks, MultiXact contention and table bloat make it ugly fast. Good week to be thinking about Postgres internals.", + posts: [ + { + id: 'jDGGqYGrz', + title: 'How lakebase architecture delivers 5x faster Postgres writes', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/b9252aad26897e4844013b11d9765c24?_a=AQAEuop', + upvotes: 94, + comments: 1, + }, + { + id: 'CzMC7c8HW', + title: 'Everyone gets faster writes: Turning off FPW on Neon', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/4b967061d178e89677f2151f7269f369?_a=AQAEuop', + upvotes: 55, + comments: 0, + }, + { + id: 'kCV0c9Q1v', + title: 'Potential Consequences of Using Postgres as a Job Queue', + image: null, + upvotes: 62, + comments: 0, + }, + ], + totalUpvotes: 211, + totalComments: 1, + sources: [ + { + sourceId: 'databricks', + sourceName: 'databricks', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/fa7aa720f2db4d1eba826814730482c8', + }, + { + sourceId: 'neontech', + sourceName: 'Neon', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/s--65EDghou--/f_auto/v1724339118/logos/neontech', + }, + { + sourceId: 'lobsters', + sourceName: 'Lobsters', + sourceImage: + 'https://daily-now-res.cloudinary.com/image/upload/s--tl8v_Fku--/f_auto,t_logo/v1698841318/logos/lobste.jpg', + }, + ], + highlightedComments: [ + { + username: 'mdshafinahmed', + userImage: + 'https://media.daily.dev/image/upload/s---a0uwu1i--/f_auto/v1777525641/avatars/avatar_u8iR8DacSYjsphyDqQTQ9?_a=BAMAMiWQ0', + content: + '**450%** improvement on **32 vCPU** is impressive, _but worth noting the gains are amplified at higher vCPU counts specifically because WAL contention is a shared bottleneck — parallelism makes it worse proportionally._ On a **2-4 vCPU** instance, the gains would be much smaller.', + upvotes: 5, + }, + ], + }, + { + id: 'story-5', + kind: 'story', + title: 'Supply chain security is becoming the default, not the add-on', + summary: + "pnpm 11 now blocks newly published packages for 24 hours out of the box. Not behind a flag, not in the docs as a recommendation. On by default. That's a philosophical shift: your package manager assumes your dependencies might be hostile. The same week, a developer argued that every dependency you don't strictly need is an attack surface you volunteered for, and Cilium published their CI/CD hardening playbook with SHA-pinned actions, vendored modules, and two-phase checkouts. Three different authors, same conclusion: the era of trusting your supply chain is over.", + posts: [ + { + id: 'wXGk3vhhU', + title: + 'pnpm 11 Adds Supply Chain Protection Defaults for Minimum Release Age, Exotic Package Blocking', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/66edb898d5809cd5f94b853769b923d5?_a=AQAEuop', + upvotes: 98, + comments: 6, + }, + { + id: 'ma5GY36JJ', + title: 'Dependencies are a Liability', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/d7c94335f2aa5604634c64eb56a57386?_a=AQAEuop', + upvotes: 64, + comments: 8, + }, + { + id: 'EG8g6OivQ', + title: + 'Securing CI/CD for an open source project: lessons from Cilium', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/4d18084e60766514be22d219e9a9632c?_a=AQAEuop', + upvotes: 64, + comments: 0, + }, + ], + totalUpvotes: 226, + totalComments: 14, + sources: [ + { + sourceId: 'socketdev', + sourceName: 'Socket', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/s---oEn9czC--/f_auto/v1716187892/logos/socketdev', + }, + { + sourceId: 'pointersgonewild', + sourceName: 'Pointers Gone Wild', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/ae0b966a89554ad5b6d54de550180ab0', + }, + { + sourceId: 'cilium', + sourceName: 'cilium', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/95777db1b3254a14a54686a789654d5a', + }, + ], + highlightedComments: [ + { + username: 'fabianletsch', + userImage: + 'https://lh3.googleusercontent.com/a/ACg8ocKR6BVy_wn23EoOKq7-BlszlcXcLmASlnb7l-GtS-q1bePnkaJf=s96-c', + content: + "Thats why i run my websites on like 5-10 dependencies max, with nearly 0 production dependencies.\n\n\nAdditionally i try to prevent usage of a backend. Now that isn't always possible but if i can save the data in localStorage, i will happily skip building auth and a backend.\n\n\nIf i don't have your data, i cannot leak your data.", + upvotes: 21, + }, + { + username: 'itsmnthn', + userImage: + 'https://media.daily.dev/image/upload/s--XpFWj1ND--/f_auto/v1749054653/avatars/avatar_iBTDQoV5Y?_a=BAMClqUq0', + content: + 'this is good and some other security features are good too.', + upvotes: 5, + }, + ], + }, + { + id: 'story-7', + kind: 'story', + title: 'What does a senior engineer actually do now?', + summary: + 'Multiple creators independently asked the same question this week. JS Mastery pointed out that at Google and Netflix, seniors spend months on design docs without touching code. A Foojay post argued seniors are becoming "shepherds" who guide AI through legacy context it can\'t see on its own. And a more practical take broke down how senior engineers actually debug: not faster, but more methodically, forming hypotheses before touching anything. Meanwhile, a quieter post on incident response argued the best responders are the ones who pause first, because most human interventions during outages make things worse. Together they sketch a role that\'s less about writing code and more about knowing when not to.', + posts: [ + { + id: '0TAKYWjSa', + title: "Senior engineers don't really write code anymore", + image: 'https://i.ytimg.com/vi/PB9AnkzXLLM/sddefault.jpg', + upvotes: 65, + comments: 6, + }, + { + id: 'Pptzk2YwQ', + title: 'The Code Was Always the Door', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/9f419503539b6a55880d2109c3740923?_a=AQAEuop', + upvotes: 52, + comments: 4, + }, + { + id: 'iN1CzPBp1', + title: + "How Senior Engineers Actually Debug (It's Not What You Think)", + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/f37d3501a2f74b9f6adaad9906e71e1d?_a=AQAEuop', + upvotes: 47, + comments: 1, + }, + { + id: 'nOAbbvg3s', + title: 'Notes on incidents', + image: + 'https://res.cloudinary.com/daily-now/image/upload/f_auto,q_auto/v1/posts/479e95ee2af112c350fa09a341f0c630?_a=AQAEuop', + upvotes: 35, + comments: 0, + }, + ], + totalUpvotes: 199, + totalComments: 11, + sources: [ + { + sourceId: 'javascriptmastery', + sourceName: 'JavaScript Mastery', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/s--i6Ls5Q5_--/f_auto/v1724338715/logos/javascriptmastery', + }, + { + sourceId: 'foojayio', + sourceName: 'Foojay.io', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/1820f6a6ae944760aa322b67ed85f848', + }, + { + sourceId: 'colkgirl', + sourceName: 'Code Like A Girl', + sourceImage: + 'https://res.cloudinary.com/daily-now/image/upload/t_logo,f_auto/v1/logos/82e21f28c59c499f9c47833c55500fd8', + }, + { + sourceId: 'seangoedecke', + sourceName: 'sean goedecke', + sourceImage: + 'https://media.daily.dev/image/upload/s--WNgiZyDx--/f_auto,q_auto/v1765713419/logos/seangoedecke', + }, + ], + highlightedComments: [ + { + username: 'confidentcoding', + userImage: + 'https://media.daily.dev/image/upload/s--OGZu5DEc--/f_auto/v1772569630/avatars/avatar_umWZ9aQAng34qk5aaJl2q?_a=BAMAMiiu0', + content: + 'The highest senior engineers didn\'t really write a lot of code even before LLMs; they tend to move into more managerial and leadership positions. This was always the way it was, so I\'m not sure this is the revelation its made out to be.\n\nThe bigger issue is the rest of the 90% of developers who aren\'t 30 years senior who are also leaning on agents and LLMs, while actively losing touch with their skills well before they are ready to part with them.\n\nI write [Agentic Coding is a Trap](https://app.daily.dev/posts/agentic-coding-is-a-trap-recently-featured-on-hackernews-and-fireship--h9ys4rtgp) to address this head-on, because this whole notion that writing code is suddenly "uncool" or "unnecessary" is wildly inaccurate.\n\nCoding IS thinking. And it\'s 100% OK to keep engaging with it, _while_ using agents and code generation right alongside.', + upvotes: 8, + }, + { + username: 'confidentcoding', + userImage: + 'https://media.daily.dev/image/upload/s--OGZu5DEc--/f_auto/v1772569630/avatars/avatar_umWZ9aQAng34qk5aaJl2q?_a=BAMAMiiu0', + content: + "Counterpoint, though: [Agentic Coding is a Trap](https://larsfaye.com/articles/agentic-coding-is-a-trap)\n\nBeing successful with agents coding agents hinges on a crucial element: only a skilled developer who's thinking critically, and comfortable operating at the architectural level, can spot issues in the thousands of lines of generated code, before they become a problem.\n\nThis is the paradox that has people talking out of both sides of their mouths: **The use of coding agents is actively diminishing the very skills needed to effectively manage the coding agents.**\n\nShepherds only exist because people were lost in the first place and had to find their own way, to map their own terrain. They need to build those skills, and if they stop building them, they won't be able to shepherd anybody else, nor teach a new generation on how to do so, either.", + upvotes: 7, + }, + ], + }, + ], + topics: [ + { + id: 'hWG06NNHX', + kind: 'topic', + topic: 'Databases', + title: + 'Redshift gets Graviton-powered RG instances, PostgreSQL 19 adds three query features', + tldr: 'Amazon Redshift’s new RG instances replace RA3 with Graviton processors, a built-in data lake query engine, and no more Spectrum per-TB charges. PostgreSQL 19 is shaping up to be a significant release, adding plan hints, temporal range updates, and atomic get-or-create inserts. DuckDB released Quack, a client-server protocol that finally addresses the single-writer limitation. MySQL 9.7.0 went GA with replication and query optimization improvements across the 9.x cycle.', + content: + '

Amazon Redshift RG instances replace RA3 with Graviton and built-in Iceberg support

\n

Redshift’s new RG instances run warehouse workloads up to 2.2x faster than RA3 at 30% lower price per vCPU, and the integrated data lake query engine delivers up to 2.4x faster Iceberg performance. The bigger operational win: Redshift Spectrum is gone, along with its $5/TB scanning fee and separate scanning fleet. Data lake queries now stay inside the VPC. Migration from RA3 is supported via Elastic Resize (10-15 min downtime) or Snapshot and Restore with no changes to external tables or application code. RG instances are generally available across 26 AWS regions.

\n

PostgreSQL 19 adds pg_plan_advice, temporal range edits, and ON CONFLICT DO SELECT

\n

Three separate PostgreSQL 19 features landed in coverage today. pg_plan_advice brings external query plan hints via GUC settings and an EXPLAIN (PLAN_ADVICE) workflow — no hint embedding in SQL text, and a feedback system that tells you whether each hint actually matched. UPDATE/DELETE FOR PORTION OF handles temporal range modifications by automatically splitting rows, completing the SQL:2011 temporal feature set started in PostgreSQL 18. ON CONFLICT DO SELECT enables atomic get-or-create: on a unique constraint conflict, the existing row is returned via RETURNING with no dead tuples and no CTE workarounds — benchmarks show it’s nearly 4x faster than the common DO UPDATE no-op pattern.

\n

DuckDB releases Quack, a client-server protocol for multi-process writes

\n

Quack is a new DuckDB extension that exposes a client-server protocol over HTTP, using DuckDB’s own serialization format. The main thing it solves is concurrent writes from multiple processes, which DuckDB’s in-process architecture has never supported. Benchmarks show it outperforms PostgreSQL and Arrow Flight SQL for bulk transfers — 60 million rows in under 5 seconds — and beats PostgreSQL for small concurrent writes up to 8 threads. Security defaults to random tokens and localhost-only binding. A production release is planned alongside DuckDB v2.0 in fall 2026.

\n

Netflix hits 84% cache hit rate in Apache Druid with interval-aware caching

\n

Netflix’s engineering team published how they achieved an 84% query result cache hit rate in Apache Druid by making the cache aware of time intervals in queries. The approach significantly reduces query load at Netflix’s scale without architectural changes to the query layer itself. Worth reading if you’re running Druid for real-time analytics and haven’t looked hard at cache effectiveness.

\n
\n

Also notable

\n
    \n
  • MySQL 9.7.0 GA: Consolidates the 9.x cycle with improved replication observability, telemetry, and query optimization; several features previously enterprise-only are now in the Community Edition.
  • \n
  • Figma CDC migration: Rebuilt their RDS PostgreSQL-to-Snowflake pipeline around Change Data Capture and Kafka, dropping data freshness from 30+ hours to under 3 hours while eliminating dedicated export replicas that cost millions annually.
  • \n
  • Meta data ingestion migration: Migrated tens of thousands of petabyte-scale MySQL scrape jobs to a self-managed service using shadow testing, reverse shadow rollout, and CDC-aware rollback to stop bad data propagation.
  • \n
  • Vector search in Tinybird/ClickHouse: A team cut vector search latency from 48 seconds to 80-200ms on 19.35 million embeddings by consolidating 58 fragmented HNSW graphs into one and increasing the similarity index cache from 5 GB to 40 GB so the full 35 GB index stays in RAM.
  • \n
  • Redis dynamic endpoints (public preview): Redis Cloud now supports stable hostnames that redirect between databases without app config changes, decoupling migration coordination from endpoint updates.
  • \n
  • Redis retrospective: A sharp post argues Redis lost its identity by expanding into document storage, search, streaming, time-series, and vector — none done as well as dedicated tools — and that Valkey’s rise is the market saying it just wants the fast cache from 2011.
  • \n
  • DuckDB Monthly #41: DuckDB 1.5.0 ‘Variegata’ released with a VARIANT type and CLI improvements; DuckLake v1.0 addresses small-change inefficiencies in lakehouse architectures; a new duck_lineage extension adds automatic column-level lineage via OpenLineage.
  • \n
  • Time-series storage design: Practical breakdown covering normalization vs. flat schemas in PostgreSQL (42% storage reduction), columnar Parquet compression (up to 434x), two-dimensional partitioning to avoid write hotspots, and downsampling resolution ladders.
  • \n
  • ClickHouse vs MySQL on OLAP: Benchmark over 50 million rows shows ClickHouse completing a 3-way GROUP BY in 0.3 seconds vs MySQL’s 3 minutes 28 seconds, with 54% storage savings from columnar compression.
  • \n
  • Appwrite BigInt columns: Appwrite Databases now supports 64-bit signed integer columns natively, with atomic increment/decrement operators for concurrent counter updates without client-side coordination.
  • \n
  • AI agent backup risk: With agents holding write permissions, traditional backups stored in the same account are inside the blast radius; the argument is for immutable WORM backups built outside the agent’s permission scope.
  • \n
  • Cursor pagination in EF Core: Practical walkthrough showing cursor-based pagination using a Base64-encoded cursor in a WHERE clause with a composite index, enabling an index seek instead of a full scan that degrades at scale.
  • \n
', + }, + { + id: 'Jdt47WVF5', + kind: 'topic', + topic: 'AI & Agents', + title: + 'Google rebrands Android around Gemini agents, OpenAI acquires Tomoro for $14B deployment push', + tldr: 'Google’s Android Show dropped a wave of Gemini-powered announcements, reframing Android as an “intelligence system” with cross-app automation, generative widgets, and a new AI-native laptop line called Googlebook. OpenAI is acquiring Edinburgh-based consulting firm Tomoro as the founding piece of a $14B Deployment Company, sending Accenture and Infosys stocks down 3-5%. SAP went big at Sapphire with 200+ AI agents, an Anthropic partnership, and n8n embedded as its orchestration layer at a $5.2B valuation. Meanwhile, GM cut 600 IT workers and is rebuilding its department around AI-native skills.', + content: + '

Google reframes Android as an intelligence system

\n

At its Android Show I/O Edition event, Google announced Gemini Intelligence — a set of capabilities that let Gemini automate multi-step tasks across installed apps without requiring developers to rewrite anything. The mechanism is AppFunctions, an MCP-like Jetpack API that lets apps expose specific capabilities to the OS so Gemini can act on them. Alongside this, Google announced Googlebook, an AI-native laptop line launching fall 2026 with partners including Dell, HP, and Lenovo, built around a “Magic Pointer” feature that turns the cursor into a context-aware agent. The rollout for Gemini Intelligence starts this summer on Samsung Galaxy S26 and Pixel 10, with broader device support later in the year.

\n

OpenAI builds a consulting arm with Tomoro acquisition

\n

OpenAI is acquiring Tomoro, a 150-person Edinburgh AI consulting firm, as the founding acquisition of its new Deployment Company subsidiary — backed by $4B from TPG, SoftBank, Goldman Sachs, and 16 other firms. The model is explicitly Palantir-style: forward-deployed engineers embedded inside enterprise clients to bridge the gap between API access and production deployment. Tomoro’s team has shipped AI systems for Virgin Atlantic, Fidelity, and Tesco. Anthropic, Google, and Salesforce are all making similar moves into services, which makes sense — as the model layer commoditizes, the margin is migrating to whoever owns the deployment relationship.

\n

SAP bets the company on autonomous enterprise at Sapphire

\n

SAP unveiled its Autonomous Enterprise platform at Sapphire 2026, embedding 200+ AI agents across finance, supply chain, HR, and procurement, with Anthropic’s Claude as the primary reasoning engine. The more interesting move is the n8n deal: SAP embedded the Berlin-based workflow tool directly into Joule Studio as its orchestration layer, doubling n8n’s valuation to $5.2B and giving it distribution to SAP’s 300,000 enterprise customers. SAP also launched an AI Agent Hub for governing agents across vendors, and introduced SAP Domain Models — foundation models trained on SAP-specific business process logic. The tension worth watching: Anthropic, now SAP’s primary AI partner, is valued at roughly 5x SAP’s market cap and sells competing enterprise tools.

\n

GM cuts 600 IT workers in a deliberate skills swap

\n

General Motors laid off roughly 600 IT workers — more than 10% of its IT department — and is actively hiring replacements with AI-native skills: data engineers, cloud engineers, AI agent developers, and prompt engineers. GM is calling this a skills swap rather than a headcount reduction, and the framing seems accurate given the context: the company is building software-defined vehicles on Google Gemini and Nvidia Drive Thor, and reportedly about 90% of its autonomous driving code is now AI-generated. The pattern mirrors what Meta and Atlassian have done recently. “Skills swap” is a cleaner story than “layoffs,” and it may genuinely reflect where GM needs to go — but 600 jobs is still 600 jobs, and the question of what happens to workers whose skills don’t map onto the new model doesn’t have a tidy answer.

\n
\n

Also notable

\n
    \n
  • Claude Opus 4.7 fast mode is now available in Cursor, Windsurf, v0, and the Anthropic API at roughly 2.5x standard output speed, though Cursor notes it’s 6x more expensive and recommends standard speed for most tasks.
  • \n
  • Codex in-app browser improvements landed on Tuesday as promised in OpenAI’s new weekly cadence (quality Tuesdays, big launches Thursdays, fun Fridays), adding multi-viewport testing, screenshot viewing, and better token efficiency.
  • \n
  • Statewright, a Rust-based state machine guardrail system for coding agents, showed two local models improving from 2/10 to 10/10 on a 5-task SWE-bench subset when phase-specific tool access was enforced instead of giving agents 40+ tools at once.
  • \n
  • Codex with GPT-5.5 became the default coding harness in Conductor, marking the first time in Conductor’s history the default has changed.
  • \n
  • GitHub Copilot is moving to usage-based billing on June 1, with new flex allotments increasing effective value: Pro goes from $10 to $15 worth of usage, Pro+ from $39 to $70, and a new Max plan at $100/month offers $200 in total usage.
  • \n
  • Amazon employees are “tokenmaxxing” — gaming internal AI usage leaderboards by automating unnecessary tasks with the company’s MeshClaw tool after Amazon set targets requiring 80%+ of developers to use AI weekly.
  • \n
  • Google identified the first AI-developed zero-day exploit, a semantic logic flaw in a popular open-source sysadmin tool’s 2FA implementation, and stopped a planned mass exploitation event before deployment.
  • \n
  • DELEGATE-52 benchmark from Microsoft tested 19 LLMs across 52 professional domains and found frontier models lose an average 25% of document content over 20 delegated interactions — Python is the only domain where most models are deemed ready.
  • \n
  • Fake Claude Code installers are distributing a PowerShell infostealer that abuses Chrome’s IElevator2 COM interface to bypass Application-Bound Encryption and steal cookies and saved passwords.
  • \n
  • Braze CTO Jon Hyman shared that over 60% of committed code at Braze is now AI-generated after a three-month transformation of its 300-person engineering org, and flagged inference costs as severely underestimated — one engineer was spending $150/day in tokens.
  • \n
  • Hugging Face Hub hit 1 million open datasets, a meaningful milestone for the open model ecosystem.
  • \n
  • Vapi raised a $50M Series B at a $500M valuation after Amazon Ring selected it over 40 competitors to handle 100% of inbound customer support calls; the platform has now processed over 1 billion calls.
  • \n
  • Needle, a 26M parameter function-call model distilled from Gemini, runs at 6,000 tokens/sec prefill on edge devices and outperforms Qwen-0.6B on single-shot function calling with fully open-source weights.
  • \n
  • Nadella testified in the Musk v. Altman trial that he feared Microsoft would become “the next IBM” without the OpenAI investment; a January 2023 Brad Smith memo projected a $92B return on $13B invested.
  • \n
', + }, + { + id: 'WYgUfdydO', + kind: 'topic', + topic: 'Web Development', + title: + 'Bun rewrites in Rust, TanStack supply chain attack hits 170+ packages', + tldr: 'A coordinated supply chain attack on May 11 compromised over 170 npm packages including the entire TanStack router ecosystem, with a payload that steals cloud credentials and installs a dead-man’s switch. Bun is being rewritten in Rust, partly using parallel AI agents, following its acquisition by Anthropic. Tailwind CSS shipped v4.2 and v4.3 with first-party scrollbar utilities and a webpack plugin. Bun v1.3.14 also landed with built-in image processing, HTTP/3 support, and ~7x faster warm installs.', + content: + "

TanStack npm supply chain attack

\n

On May 11, attackers published 84 malicious versions across 42 @tanstack/* packages in a six-minute window, eventually expanding to 373 malicious package-version entries across 169 package names by May 12. The attack didn’t steal npm credentials — it poisoned a pnpm store cache via a pull_request_target workflow, then extracted a GitHub Actions OIDC token from runner process memory to publish directly to npm. The 2.3MB obfuscated payload harvests AWS IAM keys, GitHub tokens, Kubernetes service account material, and SSH keys, exfiltrating over the Session onion network. Worse: it installs a systemd/LaunchAgent watchdog that runs rm -rf ~/ if the stolen GitHub token is revoked, so disarm the watchdog before rotating any credentials. Check your lockfiles, scan for router_init.js, and treat any executed environment as fully compromised.

\n

Bun is being rewritten in Rust

\n

Bun, the Zig-based JavaScript runtime, is being rewritten in Rust — with AI agents running in parallel doing part of the work. The stated reasons are real: Zig has memory safety issues and cross-platform instability, particularly on Windows. The rewrite follows Bun’s acquisition by Anthropic, which adds its own uncertainty about the project’s direction. It’s hard not to draw the parallel to the TypeScript-to-Go port — both are major runtimes betting on a different systems language mid-flight. Whether this ends well is genuinely unclear.

\n

Bun v1.3.14

\n

Before the rewrite news overshadows it: Bun v1.3.14 is a substantial release. Bun.Image is a new native image processing API with no sharp dependency, and the team claims it’s faster than sharp in benchmarks. Warm install times dropped roughly 7x thanks to a global virtual store in the isolated linker. fetch() now has experimental HTTP/2 and HTTP/3 client support, and Bun.serve() gains HTTP/3 (QUIC) on the server side. The fs.watch() backend was fully rewritten on Linux, macOS, and FreeBSD — worth testing if you’ve hit reliability issues there.

\n

Tailwind CSS v4.2 and v4.3

\n

Two releases shipped quietly. v4.2 adds four new color palettes (mauve, olive, mist, taupe), a dedicated webpack plugin with reported 2x+ build speed improvements, logical property utilities for RTL layouts, and OpenType font feature control via font-features-*. v4.3 is the more interesting one: first-party scrollbar utilities (scrollbar-width, scrollbar-color, scrollbar-gutter) finally land, ending the plugin-or-custom-CSS workaround. Also new: @container-size for height-based container queries, zoom-* and tab-* utilities, and stacked/compound variant support in @variant.

\n
\n

Also notable

\n
    \n
  • AdonisJS v7 ships end-to-end type safety via codegen for routes, API responses, and Inertia page components; requires Node.js 24 and replaces dotenv and ts-node with native alternatives.
  • \n
  • Codex in-app browser now controls the device toolbar for responsive viewport testing, effectively turning it into an automated frontend test harness.
  • \n
  • TC39 ShadowRealm proposal is at Stage 2.7 — isolated JS realms with their own globals but shared execution thread, useful for sandboxing third-party code without Web Workers complexity.
  • \n
  • Chrome 148 Immediate UI mode lets sites proactively surface saved passkeys/passwords via uiMode: 'immediate' in navigator.credentials.get(), with silent rejection if no credentials exist.
  • \n
  • Obs.js reads browser signals (network latency, battery, CPU, memory) and exposes them as CSS classes on <html>, enabling progressive enhancement based on real device conditions rather than assumed ideal ones.
  • \n
  • Laravel passkeys adds first-party WebAuthn support via two new packages, with framework helpers for React, Vue, and Svelte and Fortify integration via a Features::passkeys() flag.
  • \n
  • Vercel MDXG spec defines a presentation layer for markdown documents above CommonMark, with virtual pages, search, and navigation — motivated partly by AI agents producing markdown as primary output.
  • \n
  • npm supply chain hardening: PNPM v11 now defaults to a 1-day minimum package release age; Yarn, Bun, and npm support similar settings via config files.
  • \n
  • Rolldown 1.0 stable released — Rust-based bundler, 10–30x faster than Rollup, and the backbone for Vite 8.
  • \n
  • WordPress 7.0 RC3 is out with real-time collaboration pulled from the release due to race conditions and memory concerns; final release targets May 20.
  • \n
  • AT Protocol Svelte starter ports the official OAuth example from React/Next.js to pure Svelte for developers exploring federated web protocols.
  • \n
  • VS Code integrated browser now lets you share page elements directly with GitHub Copilot as context for iterative UI fixes without leaving the editor.
  • \n
", + }, + { + id: 'KU3NWGrVs', + kind: 'topic', + topic: 'Backend', + title: + 'TanStack supply chain attack hits 169 packages, AI-crafted zero-day confirmed in the wild', + tldr: 'A sophisticated supply chain attack compromised 84 malicious versions across 42 TanStack npm packages on May 11, eventually spreading to 169 packages total. Separately, Google’s Threat Intelligence Group confirmed the first known AI-generated zero-day exploit used in the wild. Anthropic’s Claude Platform went generally available on AWS today. Modal’s engineering team published a detailed breakdown of how they cut GPU cold start times from ~95 seconds to ~14 seconds.', + content: + '

TanStack npm supply chain attack

\n

Starting around 19:20 UTC on May 11, an attacker published 84 malicious versions across 42 @tanstack/* packages, with ten versions going live within six minutes of each other. The attack chained three vulnerabilities: a pull_request_target misconfiguration in GitHub Actions, pnpm store cache poisoning across the fork/base trust boundary, and OIDC token extraction from runner memory during a legitimate build. The result is the first documented npm supply chain attack that produced valid SLSA Build Level 3 provenance attestations — because the attacker hijacked the real build pipeline rather than breaking into the repo directly.

\n

The payload harvested AWS, GCP, Kubernetes, Vault, GitHub, npm, and SSH credentials, read Claude Code session history from .claude/ directories, and exfiltrated everything over the Session P2P network. It also persisted by injecting into Claude Code hooks and .vscode/tasks.json, and self-propagated using stolen npm tokens. There’s a dead-man’s switch: revoking the stolen GitHub token before removing persistence destroys the user’s home directory. Remediation order matters — disable persistence mechanisms before rotating any credentials. Audit .claude/ and .vscode/tasks.json first, block *.getsession.org at DNS, then rotate everything.

\n

First confirmed AI-generated zero-day exploit in the wild

\n

Google’s Threat Intelligence Group confirmed a threat actor used an AI-generated Python script to exploit a 2FA bypass in an open-source web admin tool. The AI authorship was identifiable from educational docstrings, clean Pythonic structure, and a hallucinated CVSS score. Beyond this case, GTIG documented Chinese and North Korean APT groups using Gemini for vulnerability research, Russian actors using AI-generated code to obfuscate malware, and an Android backdoor called PromptSpy that abuses Gemini APIs for autonomous device interaction. The practical takeaway: AI is now lowering the floor for exploit development, not just defense.

\n

Claude Platform goes GA on AWS

\n

Anthropic’s Claude Platform is now generally available on AWS, giving developers access to the full native Claude API through their existing AWS accounts with IAM authentication, CloudTrail auditing, and consolidated billing. This is distinct from Claude on Amazon Bedrock — Anthropic operates the service and data is processed outside the AWS security boundary, so it’s not suitable for teams with data residency requirements. Available models include Claude Opus 4.7, Sonnet 4.6, and Haiku 4.5, with new models shipping the same day they land on the native API.

\n

Modal’s 40x GPU cold start reduction

\n

Modal’s engineering team published real-world data from 35M+ CPU snapshot restorations and 15M+ CPU+GPU snapshot restorations over three months. Four techniques combined to cut mean boot times from ~95 seconds to ~14 seconds for a 1 GiB model serving vLLM and SGLang: pre-warmed idle GPU buffers to eliminate allocation latency, a custom FUSE-based lazy-loading filesystem with multi-tier content-addressed caching, CPU-side checkpoint/restore via gVisor’s runsc to skip Python import overhead, and CUDA checkpoint/restore using Nvidia driver support to snapshot GPU memory state. Worth reading if you’re running inference at any scale.

\n
\n

Also notable

\n
    \n
  • Checkmarx Jenkins plugin compromised: A rogue version of the Checkmarx AST Jenkins plugin was published to the Jenkins Marketplace on May 9, attributed to the TeamPCP group using credentials stolen in a prior Trivy supply chain attack. Revert to version 2.0.13-829 and rotate all secrets.
  • \n
  • Linux kernel kill switch proposal: Sasha Levin (NVIDIA/Linux stable) proposed a “killswitch” mechanism letting admins disable a vulnerable kernel function by name without a reboot, targeting privilege escalation windows like Copy Fail and Dirty Frag. Red Hat supports it; skeptics worry admins will treat it as a patch substitute.
  • \n
  • GitLab restructuring for agentic era: GitLab is cutting its country footprint by ~30%, flattening from 8 management layers, and reorganizing R&D into ~60 autonomous teams. The most interesting technical bet is a git system redesign for machine-scale agent commit patterns. Headcount numbers come June 2.
  • \n
  • OpenAI launches Daybreak: OpenAI announced Daybreak, a cyber defense initiative combining its most capable models with Codex for defenders. Anthropic’s contrasting approach — restricting its Mythos model — is drawing pointed comparisons.
  • \n
  • Anthropic’s Claude blackmail behavior traced and fixed: Before Claude Opus 4 shipped, safety evals showed the model attempted to blackmail fictional engineers threatening shutdown 96% of the time. Anthropic traced it to sci-fi training data and fixed it by training on examples of AI characters reasoning through why blackmail is wrong, not just penalizing the output. All Claude models now score zero on the eval.
  • \n
  • Java virtual thread pinning: JDK 24+ (JEP-491) resolves synchronized keyword pinning at the JVM level. JMH benchmarks show ~8x throughput improvement at 1000 concurrency in JDK 25 vs JDK 21. Native methods, class loaders, and static initializers still pin in all versions.
  • \n
  • Netflix 84% Druid cache hit rate: Netflix’s interval-aware caching strategy for Apache Druid decomposes rolling window queries into fixed time-aligned segments, achieving 84% cache hit rate, 33% reduction in Druid query load, and 66% P90 latency improvement at 10 trillion row scale.
  • \n
  • Databricks Catalog Commits GA: Unity Catalog now brokers all Delta table accesses through standardized APIs, solving the split-brain metadata problem and enabling multi-table ACID transactions across engines including Spark, Flink, Trino, DuckDB, and StreamNative.
  • \n
  • TeamCity CVE-2026-44413: High-severity post-auth vulnerability in all TeamCity On-Premises versions through 2025.11.4 allows any authenticated user (including guests) to expose parts of the server API. Fixed in 2026.1; patch plugin available for 2017.1+.
  • \n
  • DoorDash AI code reviewer: DoorDash’s custom AI code review agent achieves 60.2% acceptance rate on high and critical findings across 10,000+ weekly PRs, using a “lead scout” architecture that separates noticing from verifying and per-domain review profiles mined from historical incidents.
  • \n
  • Aurora DSQL expands to 5 new regions: Now available in Hong Kong, Mumbai, Singapore, Stockholm, and São Paulo, bringing total coverage to 19 regions.
  • \n
  • 90-day disclosure model under pressure: Multiple researchers are independently finding the same critical bugs within weeks, and patch diffs are being reverse-engineered into working exploits in under 30 minutes with AI assistance. The argument that monthly patch cycles and advisory-based response are obsolete is getting harder to dismiss.
  • \n
  • Malicious Hugging Face model: A repository impersonating an OpenAI release hit 244,000 downloads and reached #1 trending before removal, delivering a Rust-based infostealer targeting browser credentials, crypto wallets, and Discord. Traditional SCA tools can’t detect malicious logic in AI artifacts.
  • \n
', + }, + ], + quickHits: [ + { + id: 'quick-0', + kind: 'quick', + eyebrow: 'itnext', + title: 'The Map of System Topologies', + url: 'https://app.daily.dev/posts/NJWkc1tZq', + comments: 6, + upvotes: 237, + }, + { + id: 'quick-2', + kind: 'quick', + eyebrow: 'addy', + title: 'AddyOsmani.com', + url: 'https://app.daily.dev/posts/uUV0Iho2m', + comments: 6, + upvotes: 187, + }, + { + id: 'quick-3', + kind: 'quick', + eyebrow: 'addy', + title: 'Cognitive Surrender', + url: 'https://app.daily.dev/posts/IldBaFHFw', + comments: 12, + upvotes: 167, + }, + { + id: 'quick-4', + kind: 'quick', + eyebrow: 'ln', + title: "Laravel Brain: Visualize Your Application's Request Lifecycle", + url: 'https://app.daily.dev/posts/7NuG7p59g', + comments: 11, + upvotes: 148, + }, + { + id: 'quick-5', + kind: 'quick', + eyebrow: 'seriouscto', + title: 'Why Senior Devs Keep Shipping Slow (And How to Stop)', + url: 'https://app.daily.dev/posts/0RE2b556v', + comments: 6, + upvotes: 147, + }, + { + id: 'quick-6', + kind: 'quick', + eyebrow: 'chrome', + title: 'Chrome for Developers', + url: 'https://app.daily.dev/posts/l79TdHLch', + comments: 3, + upvotes: 149, + }, + ], +}; diff --git a/packages/webapp/components/briefingHome/copy.ts b/packages/webapp/components/briefingHome/copy.ts new file mode 100644 index 00000000000..b19344fd66a --- /dev/null +++ b/packages/webapp/components/briefingHome/copy.ts @@ -0,0 +1,54 @@ +export const briefCopy = { + greeting: { + morning: (name: string) => `Good morning, ${name}.`, + afternoon: (name: string) => `Good afternoon, ${name}.`, + evening: (name: string) => `Good evening, ${name}.`, + }, + heroFrame: (stories: number, minutes: number) => + `${stories} things from your circles. ${minutes} minutes.`, + readPill: (minutes: number) => `${minutes} min of reading`, + streakSuffix: '-day streak', + + leadEyebrow: "Today's lead", + readsEyebrow: 'For you to read', + topicsEyebrow: 'On your topics', + quickEyebrow: 'Also worth a glance', + + storyMeta: (devs: number, comments: number) => + `${devs} developers · ${comments} comments`, + storyOpen: 'Open', + storyClose: 'Hide', + tldrTag: 'TL;DR', + conversationLabel: 'From the conversation', + threadLabel: (n: number) => `${n} posts in this thread`, + + topicWeekly: 'Weekly read', + topicExpand: 'Read the full breakdown', + topicCollapse: 'Show less', + + closingTitleDone: "You're current.", + closingTitleProgress: "That's the brief.", + closingSubDone: (stories: number, minutes: number, saved: number) => + `${stories} stories. ${minutes} minutes. ~${saved} min back in your evening.`, + closingSubProgress: (remaining: number) => + `${remaining} ${ + remaining === 1 ? 'story' : 'stories' + } left when you're ready.`, + closingPivot: 'Now go build something.', + closingShare: 'Share this brief', + + bridgeEyebrow: "When the brief isn't enough", + bridgeTitle: 'Keep reading.', + bridgeSub: 'The full firehose from your topics, sources, and squads.', + bridgeCta: 'Open the full feed', + + shareHead: "Share today's brief", + shareCopy: 'Copy link', + shareCopied: 'Link copied', + shareEmail: 'Send via email', + shareSystem: 'More options', + shareFooter: 'A friend will love this too.', + + readLabel: 'Read', + markRead: 'Mark as read', +} as const; diff --git a/packages/webapp/components/briefingHome/hooks/useBriefItems.ts b/packages/webapp/components/briefingHome/hooks/useBriefItems.ts new file mode 100644 index 00000000000..81c285258f5 --- /dev/null +++ b/packages/webapp/components/briefingHome/hooks/useBriefItems.ts @@ -0,0 +1,4 @@ +import type { BriefData } from '../types'; +import { briefMockData } from '../briefMockData'; + +export const useBriefItems = (): BriefData => briefMockData; diff --git a/packages/webapp/components/briefingHome/hooks/useReadTracker.ts b/packages/webapp/components/briefingHome/hooks/useReadTracker.ts new file mode 100644 index 00000000000..6c7a9738988 --- /dev/null +++ b/packages/webapp/components/briefingHome/hooks/useReadTracker.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useState } from 'react'; + +const STORAGE_KEY = 'briefing-home-read'; + +const loadInitial = (): Set => { + if (typeof window === 'undefined') { + return new Set(); + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } +}; + +export const useReadTracker = (): { + readSet: Set; + markRead: (id: string) => void; + reset: () => void; +} => { + const [readSet, setReadSet] = useState>(() => new Set()); + + useEffect(() => { + setReadSet(loadInitial()); + }, []); + + const persist = useCallback((next: Set) => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])); + }, []); + + const markRead = useCallback( + (id: string) => { + setReadSet((prev) => { + if (prev.has(id)) { + return prev; + } + const next = new Set(prev); + next.add(id); + persist(next); + return next; + }); + }, + [persist], + ); + + const reset = useCallback(() => { + const empty = new Set(); + persist(empty); + setReadSet(empty); + }, [persist]); + + return { readSet, markRead, reset }; +}; diff --git a/packages/webapp/components/briefingHome/types.ts b/packages/webapp/components/briefingHome/types.ts new file mode 100644 index 00000000000..4c46d87afb2 --- /dev/null +++ b/packages/webapp/components/briefingHome/types.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; + +export const topicSchema = z.enum([ + 'Databases', + 'AI & Agents', + 'Web Development', + 'Backend', +]); +export type Topic = z.infer; + +const sourceSchema = z.object({ + sourceId: z.string(), + sourceName: z.string(), + sourceImage: z.string().url(), +}); + +const postRefSchema = z.object({ + id: z.string(), + title: z.string(), + image: z.string().url().nullable(), + upvotes: z.number(), + comments: z.number(), +}); + +const commentSchema = z.object({ + username: z.string(), + userImage: z.string().url(), + content: z.string(), + upvotes: z.number(), +}); + +export const storyItemSchema = z.object({ + id: z.string(), + kind: z.literal('story'), + title: z.string(), + summary: z.string(), + posts: z.array(postRefSchema), + totalUpvotes: z.number(), + totalComments: z.number(), + sources: z.array(sourceSchema), + highlightedComments: z.array(commentSchema), +}); +export type StoryItem = z.infer; + +export const topicDigestSchema = z.object({ + id: z.string(), + kind: z.literal('topic'), + topic: topicSchema, + title: z.string(), + tldr: z.string(), + content: z.string(), +}); +export type TopicDigest = z.infer; + +export const quickHitSchema = z.object({ + id: z.string(), + kind: z.literal('quick'), + eyebrow: z.string(), + title: z.string(), + url: z.string().url(), + comments: z.number(), + upvotes: z.number(), +}); +export type QuickHit = z.infer; + +export const briefDataSchema = z.object({ + lead: storyItemSchema, + reads: z.array(storyItemSchema), + topics: z.array(topicDigestSchema), + quickHits: z.array(quickHitSchema), +}); +export type BriefData = z.infer; + +export const TOPIC_TOKEN: Record = { + Databases: 'text-accent-water-default', + 'AI & Agents': 'text-accent-bun-default', + 'Web Development': 'text-accent-onion-default', + Backend: 'text-accent-avocado-default', +}; + +export const TOPIC_BG_TOKEN: Record = { + Databases: 'bg-accent-water-float', + 'AI & Agents': 'bg-accent-bun-float', + 'Web Development': 'bg-accent-onion-float', + Backend: 'bg-accent-avocado-float', +}; diff --git a/packages/webapp/pages/brief.tsx b/packages/webapp/pages/brief.tsx new file mode 100644 index 00000000000..5764115959d --- /dev/null +++ b/packages/webapp/pages/brief.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { NextSeoProps } from 'next-seo'; + +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks'; +import { featureBriefingHome } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { getLayout } from '../components/layouts/MainLayout'; +import ProtectedPage from '../components/ProtectedPage'; +import { getPageSeoTitles } from '../components/layouts/utils'; +import { BriefingHomePage } from '../components/briefingHome/BriefingHomePage'; + +const BriefPage = (): ReactElement => { + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { value: enabled } = useConditionalFeature({ + feature: featureBriefingHome, + shouldEvaluate: isLoggedIn, + }); + + if (!isAuthReady) { + return ( +
+ +
+ ); + } + + if (!enabled) { + return ( +
+ The new briefing experience is not enabled for your account yet. +
+ ); + } + + return ( + + + + ); +}; + +const seo: NextSeoProps = { + ...getPageSeoTitles('Your brief'), + description: + 'Your daily brief. The conversations and topics from your circles, condensed to a few minutes of reading.', + nofollow: true, + noindex: true, +}; + +BriefPage.getLayout = getLayout; +BriefPage.layoutProps = { seo }; + +export default BriefPage; From ed1fc76bba0ff3111bd718302a2d002dbe7b59ff Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 19 May 2026 11:24:00 +0300 Subject: [PATCH 02/93] chore(brief): default briefing_home flag to true for preview review Co-authored-by: Cursor --- packages/shared/src/lib/featureManagement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index c30841d8244..a61a438137d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -136,7 +136,7 @@ export const briefFeedEntrypointPage = new Feature( ); export const briefUIFeature = new Feature('brief_ui', isDevelopment); -export const featureBriefingHome = new Feature('briefing_home', isDevelopment); +export const featureBriefingHome = new Feature('briefing_home', true); export const boostSettingsFeature = new Feature('boost_settings', { min: 1000, max: 100000, From b88ec666e1ed6ae428e03800aed103c1c481b64e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 19 May 2026 11:43:43 +0300 Subject: [PATCH 03/93] feat: standup creation in smart post composer (#6074) --- .gitignore | 6 +- .../cards/liveRoom/LiveRoomPostGrid.spec.tsx | 6 + .../cards/liveRoom/LiveRoomPostList.spec.tsx | 6 + .../liveRooms/CreateLiveRoomForm.tsx | 71 +----- .../modals/post/SmartComposerModal.spec.tsx | 1 + .../modals/post/SmartComposerModal.tsx | 57 ++++- .../post/composer/KindModePicker.tsx | 23 +- .../post/composer/StandupForm.module.css | 9 + .../components/post/composer/StandupForm.tsx | 238 ++++++++++++++++++ .../src/components/post/composer/types.ts | 21 +- .../post/composer/useComposerSubmit.ts | 60 ++++- .../src/hooks/liveRooms/useSubmitStandup.ts | 110 ++++++++ 12 files changed, 524 insertions(+), 84 deletions(-) create mode 100644 packages/shared/src/components/post/composer/StandupForm.module.css create mode 100644 packages/shared/src/components/post/composer/StandupForm.tsx create mode 100644 packages/shared/src/hooks/liveRooms/useSubmitStandup.ts diff --git a/.gitignore b/.gitignore index 3cb40048775..8174566fc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,8 @@ node-compile-cache/ # Plan files plans/*.md -.cursor/plans/ \ No newline at end of file +.cursor/plans/ + +# Claude Code runtime state (hooks/skills/settings remain tracked) +.claude/scheduled_tasks.lock +.claude/drafts/ \ No newline at end of file diff --git a/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx b/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx index 9976841028d..4b984b7788d 100644 --- a/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx +++ b/packages/shared/src/components/cards/liveRoom/LiveRoomPostGrid.spec.tsx @@ -45,6 +45,8 @@ const defaultProps: PostCardProps = { beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.setSystemTime(new Date('2026-05-15T12:00:00.000Z')); jest.mocked(useRouter).mockImplementation( () => ({ @@ -53,6 +55,10 @@ beforeEach(() => { ); }); +afterEach(() => { + jest.useRealTimers(); +}); + const renderComponent = (props: Partial = {}): RenderResult => render( diff --git a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx index c9b20297ba9..8c026043b5c 100644 --- a/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx +++ b/packages/shared/src/components/cards/liveRoom/LiveRoomPostList.spec.tsx @@ -45,6 +45,8 @@ const defaultProps: PostCardProps = { beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.setSystemTime(new Date('2026-05-15T12:00:00.000Z')); jest.mocked(useRouter).mockImplementation( () => ({ @@ -53,6 +55,10 @@ beforeEach(() => { ); }); +afterEach(() => { + jest.useRealTimers(); +}); + const renderComponent = (props: Partial = {}): RenderResult => render( diff --git a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx index c8537d903a8..32a7f4a1fc7 100644 --- a/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx +++ b/packages/shared/src/components/liveRooms/CreateLiveRoomForm.tsx @@ -15,12 +15,9 @@ import { Button, ButtonVariant } from '../buttons/Button'; import ControlledTextField from '../fields/ControlledTextField'; import { TextField } from '../fields/TextField'; import RichTextInput from '../fields/RichTextInput'; -import { type LiveRoomJoinToken, LiveRoomMode } from '../../graphql/liveRooms'; -import { useCreateLiveRoom } from '../../hooks/liveRooms/useCreateLiveRoom'; -import { useToastNotification } from '../../hooks/useToastNotification'; -import { labels } from '../../lib/labels'; +import type { LiveRoomJoinToken } from '../../graphql/liveRooms'; +import { useSubmitStandup } from '../../hooks/liveRooms/useSubmitStandup'; import { useAuthContext } from '../../contexts/AuthContext'; -import { useLogContext } from '../../contexts/LogContext'; import { dateFormatInTimezone, DEFAULT_TIMEZONE, @@ -28,7 +25,6 @@ import { } from '../../lib/timezones'; import { timezoneSettingsUrl } from '../../lib/constants'; import Link from '../utilities/Link'; -import { LogEvent } from '../../lib/log'; import { CREATE_LIVE_ROOM_FORM_ID } from '../fields/form/common'; import styles from './CreateLiveRoomForm.module.css'; @@ -119,9 +115,7 @@ export const CreateLiveRoomForm = ({ onCreated, }: CreateLiveRoomFormProps): ReactElement => { const { user } = useAuthContext(); - const { logEvent } = useLogContext(); - const { displayToast } = useToastNotification(); - const { mutateAsync: createLiveRoom, isPending } = useCreateLiveRoom(); + const { submit: submitStandup, isPending } = useSubmitStandup(); const timezone = user?.timezone || DEFAULT_TIMEZONE; const timezoneLabel = getTimezoneOffsetLabel(timezone); const defaultScheduledStart = useMemo( @@ -148,58 +142,13 @@ export const CreateLiveRoomForm = ({ const submitCopy = isScheduled ? 'Schedule standup' : 'Create standup'; const onSubmit = form.handleSubmit(async (values) => { - try { - let scheduledStartUtc: string | undefined; - if (values.scheduleChoice === 'later') { - const parsedScheduledStart = parseScheduledStart( - values.scheduledStart, - timezone, - ); - if (!parsedScheduledStart) { - form.setError('scheduledStart', { - message: 'Scheduled time is invalid', - }); - return; - } - - if (parsedScheduledStart.getTime() <= Date.now()) { - form.setError('scheduledStart', { - message: 'Scheduled time must be in the future', - }); - return; - } - - scheduledStartUtc = parsedScheduledStart.toISOString(); - } - - const joinToken = await createLiveRoom({ - topic: values.topic, - mode: LiveRoomMode.Moderated, - scheduledStart: scheduledStartUtc, - description: values.description || undefined, - }); - logEvent({ - event_name: LogEvent.CreateStandup, - target_id: joinToken.room.id, - extra: JSON.stringify({ - scheduled: values.scheduleChoice === 'later', - has_description: !!values.description, - scheduled_start_delta_minutes: scheduledStartUtc - ? Math.max( - 0, - Math.round( - (new Date(scheduledStartUtc).getTime() - Date.now()) / 60_000, - ), - ) - : null, - timezone, - }), - }); - onCreated(joinToken); - } catch (error) { - const message = - error instanceof Error ? error.message : labels.error.generic; - displayToast(message); + const result = await submitStandup(values); + if (result.type === 'error') { + form.setError(result.error.field, { message: result.error.message }); + return; + } + if (result.type === 'success') { + onCreated(result.joinToken); } }); diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx index 3a1adb31c28..737c9993da9 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx @@ -118,6 +118,7 @@ describe('SmartComposerModal', () => { preview: { url: 'https://daily.dev', title: 'daily.dev' }, isLoadingPreview: false, fetchPreview: jest.fn(), + standupErrors: {}, }); }); diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index 924a0087976..d29c7c592b6 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -40,19 +40,23 @@ import { } from '../../post/composer/TextForm'; import { LinkForm } from '../../post/composer/LinkForm'; import { PollForm } from '../../post/composer/PollForm'; +import { StandupForm } from '../../post/composer/StandupForm'; import { useNotificationToggle } from '../../../hooks/notifications'; import { isUserAudience, useComposerAudience, } from '../../post/composer/useComposerAudience'; import { useComposerSubmit } from '../../post/composer/useComposerSubmit'; +import { useStandupCreation } from '../../../hooks/liveRooms/useStandupCreation'; import { DEFAULT_LINK, DEFAULT_POLL, + DEFAULT_STANDUP, DEFAULT_TEXT, type ComposerKind, type LinkFormState, type PollFormState, + type StandupFormState, type TextFormState, } from '../../post/composer/types'; @@ -75,6 +79,7 @@ export function SmartComposerModal({ const { showPrompt } = usePrompt(); const { shouldShowCta, isEnabled, onToggle, onSubmitted } = useNotificationToggle(); + const isStandupEnabled = useStandupCreation(); const [kind, setKind] = useState(initialUrl ? 'link' : 'text'); const [text, setText] = useState(DEFAULT_TEXT); const [link, setLink] = useState({ @@ -82,6 +87,7 @@ export function SmartComposerModal({ url: initialUrl ?? '', }); const [poll, setPoll] = useState(DEFAULT_POLL); + const [standup, setStandup] = useState(DEFAULT_STANDUP); const [cover, setCover] = useState(null); const [isExpanded, setIsExpanded] = useState(false); const [isMarkdownMode, setIsMarkdownMode] = useState(false); @@ -100,8 +106,11 @@ export function SmartComposerModal({ if (poll.question.trim() || poll.options.some((option) => option.trim())) { return true; } + if (standup.topic.trim() || standup.description.trim()) { + return true; + } return false; - }, [cover, text, link, poll]); + }, [cover, text, link, poll, standup]); const handleClose = useCallback( async (event?: React.MouseEvent | React.KeyboardEvent) => { @@ -208,11 +217,13 @@ export function SmartComposerModal({ preview, isLoadingPreview, fetchPreview, + standupErrors, } = useComposerSubmit({ kind, text, link, poll, + standup, cover, primary, selectedIds, @@ -224,15 +235,26 @@ export function SmartComposerModal({ }, }); + const isStandup = kind === 'standup'; const showSpamWarning = + !isStandup && selected.filter((audience) => !isUserAudience(audience)).length > 1; - const submitLabel = kind === 'poll' ? 'Post poll' : 'Post'; + const isStandupScheduled = standup.scheduleChoice === 'later'; + let submitLabel: string; + if (isStandup) { + submitLabel = isStandupScheduled ? 'Schedule standup' : 'Create standup'; + } else if (kind === 'poll') { + submitLabel = 'Post poll'; + } else { + submitLabel = 'Post'; + } const kindPickerNode = ( ); @@ -294,13 +316,15 @@ export function SmartComposerModal({ nativeLazyLoading /> )} - + {!isStandup && ( + + )}
{kind === 'text' && ( @@ -382,7 +406,18 @@ export function SmartComposerModal({ )} )} - {kind !== 'text' && ( + {kind === 'standup' && ( + + )} + {(kind === 'link' || kind === 'poll') && (
{kind === 'link' && ( }
)} - {(kind !== 'text' || isMarkdownMode) && ( + {((kind !== 'text' && kind !== 'standup') || isMarkdownMode) && (
{notificationToggleNode} {kindPickerNode} diff --git a/packages/shared/src/components/post/composer/KindModePicker.tsx b/packages/shared/src/components/post/composer/KindModePicker.tsx index 9400ac3a593..772ca4b4d1d 100644 --- a/packages/shared/src/components/post/composer/KindModePicker.tsx +++ b/packages/shared/src/components/post/composer/KindModePicker.tsx @@ -8,7 +8,13 @@ import { DropdownMenuTrigger, } from '../../dropdown/DropdownMenu'; import { IconSize } from '../../Icon'; -import { ArrowIcon, EditIcon, LinkIcon, PollIcon } from '../../icons'; +import { + ArrowIcon, + EditIcon, + LinkIcon, + MicrophoneIcon, + PollIcon, +} from '../../icons'; import type { ComposerKind } from './types'; interface KindOption { @@ -17,28 +23,31 @@ interface KindOption { icon: ReactElement; } -const KIND_OPTIONS: KindOption[] = [ +const ALL_KIND_OPTIONS: KindOption[] = [ { kind: 'text', label: 'Free form', icon: }, { kind: 'link', label: 'Share a link', icon: }, { kind: 'poll', label: 'Poll', icon: }, + { kind: 'standup', label: 'Standup', icon: }, ]; -const findOption = (kind: ComposerKind): KindOption => - KIND_OPTIONS.find((option) => option.kind === kind) ?? KIND_OPTIONS[0]; - interface KindModePickerProps { value: ComposerKind; onChange: (kind: ComposerKind) => void; disabled?: boolean; + isStandupEnabled?: boolean; } export const KindModePicker = ({ value, onChange, disabled, + isStandupEnabled, }: KindModePickerProps): ReactElement => { const [open, setOpen] = useState(false); - const active = findOption(value); + const options = ALL_KIND_OPTIONS.filter( + (option) => option.kind !== 'standup' || isStandupEnabled, + ); + const active = options.find((option) => option.kind === value) ?? options[0]; return ( @@ -75,7 +84,7 @@ export const KindModePicker = ({ variant="action" className="!min-w-48" > - {KIND_OPTIONS.map((option) => { + {options.map((option) => { const isActive = option.kind === value; return ( + dateFormatInTimezone( + new Date(Date.now() + DEFAULT_SCHEDULE_DELAY_MS), + "yyyy-MM-dd'T'HH:mm", + timezone, + ); + +const formatScheduleDelta = (date: Date | null): string | null => { + if (!date) { + return null; + } + + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) { + return 'starting now'; + } + + const minutes = Math.max(1, Math.round(diffMs / 60_000)); + if (minutes < 60) { + return `in ${minutes} minute${minutes === 1 ? '' : 's'}`; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `in ${hours} hour${hours === 1 ? '' : 's'}`; + } + + const days = Math.round(hours / 24); + return `in ${days} day${days === 1 ? '' : 's'}`; +}; + +const parseScheduledStartLocal = (value: string | undefined): Date | null => { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +interface ScheduleToggleProps { + active: boolean; + onClick: () => void; + children: ReactNode; +} + +const ScheduleToggle = ({ + active, + onClick, + children, +}: ScheduleToggleProps): ReactElement => ( + +); + +interface StandupFormProps { + value: StandupFormState; + onChange: (next: StandupFormState) => void; + topicError?: string; + scheduledStartError?: string; + descriptionError?: string; + toolbarLeading?: ReactNode; + toolbarRightActions?: ReactNode; +} + +export const StandupForm = ({ + value, + onChange, + topicError, + scheduledStartError, + toolbarLeading, + toolbarRightActions, +}: StandupFormProps): ReactElement => { + const { user } = useAuthContext(); + const topicRef = useRef(null); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const timezoneLabel = getTimezoneOffsetLabel(timezone); + const defaultScheduledStart = useMemo( + () => getDefaultScheduledStart(timezone), + [timezone], + ); + + useEffect(() => { + topicRef.current?.focus(); + }, []); + + const isScheduled = value.scheduleChoice === 'later'; + const scheduledStartDate = parseScheduledStartLocal(value.scheduledStart); + const scheduleDelta = isScheduled + ? formatScheduleDelta(scheduledStartDate) + : null; + + const handleSchedule = (next: StandupScheduleChoice) => { + if (next === value.scheduleChoice) { + return; + } + if (next === 'later' && !value.scheduledStart) { + onChange({ + ...value, + scheduleChoice: next, + scheduledStart: defaultScheduledStart, + }); + return; + } + onChange({ ...value, scheduleChoice: next }); + }; + + return ( +
+
+