Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/acctual/.replay/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"qa-project-id": "proj-exampleapps-pr-8-acctual-mqfn4alk"
}
3 changes: 3 additions & 0 deletions apps/blamy-notes/.replay/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"qa-project-id": "proj-exampleapps-pr-8-blamy-notes-mqfn4csf"
}
10 changes: 9 additions & 1 deletion apps/blamy-notes/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,15 @@ export default function App() {
)}
{reposQuery.isError && !githubNotConnected && (
<div className="gb-sidebar-note">
Failed to load repositories: {String(reposQuery.error)}
<div>
Couldn't load your repositories. Please sign in again and retry.
</div>
<button
className="gb-retry-btn"
onClick={() => reposQuery.refetch()}
>
Retry
</button>
</div>
)}
{reposQuery.data && repos.length === 0 && (
Expand Down
2 changes: 2 additions & 0 deletions apps/blamy-notes/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@
.gb-section-label { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-foreground); padding: 8px 10px 4px; }
.gb-sidebar-note { font-size: 13px; color: var(--muted-foreground); padding: 6px 10px; }
.gb-sidebar-note a { text-decoration: underline; }
.gb-retry-btn { margin-top: 6px; font-size: 12px; padding: 3px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--background); color: var(--foreground); cursor: pointer; }
.gb-retry-btn:hover { background: var(--muted); }
.repo-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: 6px; font-size: 13.5px; color: var(--foreground); text-align: left; }
.repo-item:hover { background: var(--accent); }
.repo-item-active { background: var(--accent); font-weight: 500; }
Expand Down
3 changes: 3 additions & 0 deletions apps/digg-clone/.replay/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"qa-project-id": "proj-exampleapps-pr-8-digg-clone-mqfn4eag"
}
67 changes: 56 additions & 11 deletions apps/digg-clone/netlify/functions/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ interface HnHit {
author?: string
}

type LiveSource = "gdelt" | "hn"

interface CachedStories {
source: "gdelt" | "hn"
source: LiveSource
expiresAt: number
stories: Story[]
}
Expand All @@ -51,6 +53,12 @@ const GDELT_URL = "https://api.gdeltproject.org/api/v2/doc/doc"
const HN_URL = "https://hn.algolia.com/api/v1"
const CACHE_TTL_MS = 1000 * 60 * 5
const GDELT_TIMEOUT_MS = 2000
// Maximum time the /feed request will block waiting for the live external
// aggregation. If the (cold) live fetch exceeds this, we return the local
// stories immediately and let the live fetch finish in the background to warm
// the per-source caches for subsequent requests, so the feed never blocks the
// page for multiple seconds on a cache miss.
const LIVE_BUDGET_MS = 600
const liveCache = new Map<string, CachedStories>()
const storyIndex = new Map<string, Story>()

Expand Down Expand Up @@ -389,23 +397,60 @@ function sortStories(stories: Story[], feed: FeedId) {
)
}

function mergeLiveResponse(feed: FeedId, local: Story[], live: { source: LiveSource; stories: Story[] }) {
const stories = sortStories(dedupeStories([...local, ...live.stories]), feed)
return {
feed,
source: (live.stories.length > 0 && local.length > 0 ? "mixed" : live.source) as
| "mixed"
| LiveSource,
stories,
}
}

function localFeedResponse(feed: FeedId, local: Story[]) {
return {
feed,
source: "local" as const,
stories: sortStories(local.length > 0 ? local : localStories, feed),
}
}

async function feedResponse(feed: FeedId, q = "") {
const local = localStoriesFor(feed, q)
try {
const live = await fetchLiveStories(feed, q)
const stories = sortStories(dedupeStories([...local, ...live.stories]), feed)
return {
feed,
source: live.stories.length > 0 && local.length > 0 ? "mixed" : live.source,
stories,
}
} catch {

// Race the live aggregation against a fixed budget. On a warm cache the live
// fetch resolves almost instantly and we return the merged "mixed" feed. On a
// cold cache (where the external sources can take ~2s+) the budget wins, we
// return local stories immediately, and the still-pending live fetch warms the
// per-source caches for the next request instead of blocking this one.
const livePromise = fetchLiveStories(feed, q).catch((error) => {
// Swallow here so an unhandled rejection isn't logged when the budget wins;
// the awaiting branch below re-checks the settled result.
return { __error: error } as const
})

let timer: ReturnType<typeof setTimeout> | undefined
const budget = new Promise<"timeout">((resolve) => {
timer = setTimeout(() => resolve("timeout"), LIVE_BUDGET_MS)
})

const winner = await Promise.race([livePromise, budget])
if (timer) clearTimeout(timer)

if (winner === "timeout") {
return localFeedResponse(feed, local)
}

if (winner && typeof winner === "object" && "__error" in winner) {
return {
feed,
source: feed === "saved" ? "local" : "fallback",
source: feed === "saved" ? ("local" as const) : ("fallback" as const),
stories: sortStories(local.length > 0 ? local : localStories, feed),
}
}

return mergeLiveResponse(feed, local, winner)
}

function findStory(id: string) {
Expand Down
21 changes: 16 additions & 5 deletions apps/digg-clone/src/components/main-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo } from "react"
import { CirclePlus, Search } from "lucide-react"
import { useCommunities, useStories } from "@/queries/stories"
import { useUIStore } from "@/store/ui-store"
import { useSavedStore } from "@/store/saved-store"
import type { FeedId, Story } from "@/lib/types"
import { SearchView } from "@/components/search-view"
import { StoryList } from "@/components/story-list"
Expand Down Expand Up @@ -43,11 +44,18 @@ export function MainView() {
const toggleSearch = useUIStore((state) => state.toggleSearch)
const { data: communities = [] } = useCommunities()
const { data, isLoading } = useStories(feed === "search" ? "all" : feed)
const savedStories = useSavedStore((state) => state.stories)

const stories = useMemo(
() => sortStories(data?.stories ?? [], feedSort),
[data?.stories, feedSort]
)
const stories = useMemo(() => {
// The Saved feed is derived client-side: the feed API runs on stateless
// serverless instances and cannot reliably return saved stories, so we read
// them from the locally-persisted saved store instead.
const source =
feed === "saved"
? Object.values(savedStories)
: data?.stories ?? []
return sortStories(source, feedSort)
}, [feed, savedStories, data?.stories, feedSort])

const chooseFeed = (value: string) => {
setFeed(value as FeedId)
Expand Down Expand Up @@ -143,7 +151,10 @@ export function MainView() {

<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-16 sm:px-10 lg:px-12">
<div className="mx-auto max-w-[980px]">
<StoryList stories={stories} loading={isLoading} />
<StoryList
stories={stories}
loading={feed === "saved" ? false : isLoading}
/>
</div>
</div>
</section>
Expand Down
2 changes: 1 addition & 1 deletion apps/digg-clone/src/components/new-post-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function NewPostDialog() {

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl gap-0 overflow-hidden rounded-[24px] p-0">
<DialogContent className="max-w-2xl gap-0 overflow-hidden rounded-[24px] bg-white p-0">
<DialogTitle className="sr-only">New post</DialogTitle>
<div className="border-b border-[#ececf1] px-6 py-5">
<h2 className="text-2xl font-black">New post</h2>
Expand Down
18 changes: 12 additions & 6 deletions apps/digg-clone/src/components/story-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { timeAgo } from "@/lib/format"
import type { Story, VoteState } from "@/lib/types"
import { useToggleSaveStory, useVoteStory } from "@/queries/stories"
import { useUIStore } from "@/store/ui-store"
import { useSavedStore } from "@/store/saved-store"
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -29,6 +30,9 @@ export function StoryCard({ story }: { story: Story }) {
const selectStory = useUIStore((state) => state.selectStory)
const vote = useVoteStory()
const save = useToggleSaveStory()
// Saved state is tracked client-side (the serverless feed API is stateless),
// so reflect the locally-persisted saved set rather than the server flag.
const isSaved = useSavedStore((state) => Boolean(state.stories[story.id]))
const hasImage = story.imageUrl && !imageFailed

const copyLink = async () => {
Expand Down Expand Up @@ -67,7 +71,7 @@ export function StoryCard({ story }: { story: Story }) {
</button>
</div>

<div className="flex w-10 shrink-0 justify-end pt-1 text-[16px] text-[#777887]">
<div className="flex w-10 shrink-0 justify-end pt-1 text-[16px] text-[#5d5e69]">
{timeAgo(story.publishedAt)}
</div>

Expand Down Expand Up @@ -144,13 +148,13 @@ export function StoryCard({ story }: { story: Story }) {
</a>
<button
aria-label="Save story"
onClick={() => save.mutate(story)}
onClick={() => save.mutate({ ...story, saved: isSaved })}
className={cn(
"transition-colors hover:text-[#17181f]",
story.saved && "text-[#17181f]"
isSaved && "text-[#17181f]"
)}
>
<Bookmark className="size-5" fill={story.saved ? "currentColor" : "none"} />
<Bookmark className="size-5" fill={isSaved ? "currentColor" : "none"} />
</button>
<button
aria-label="Share story"
Expand All @@ -172,8 +176,10 @@ export function StoryCard({ story }: { story: Story }) {
View story
</DropdownMenuItem>
<DropdownMenuItem onClick={copyLink}>Copy link</DropdownMenuItem>
<DropdownMenuItem onClick={() => save.mutate(story)}>
{story.saved ? "Remove from saved" : "Save story"}
<DropdownMenuItem
onClick={() => save.mutate({ ...story, saved: isSaved })}
>
{isSaved ? "Remove from saved" : "Save story"}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={story.url} target="_blank" rel="noreferrer">
Expand Down
8 changes: 5 additions & 3 deletions apps/digg-clone/src/components/story-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useVoteStory,
} from "@/queries/stories"
import { useUIStore } from "@/store/ui-store"
import { useSavedStore } from "@/store/saved-store"
import {
Dialog,
DialogContent,
Expand All @@ -30,6 +31,7 @@ function nextVote(current: VoteState, vote: VoteState) {
function DetailBody({ story }: { story: Story }) {
const vote = useVoteStory()
const save = useToggleSaveStory()
const isSaved = useSavedStore((state) => Boolean(state.stories[story.id]))

const copyLink = async () => {
await navigator.clipboard.writeText(story.url)
Expand Down Expand Up @@ -84,13 +86,13 @@ function DetailBody({ story }: { story: Story }) {
<Button
variant="secondary"
className="rounded-full"
onClick={() => save.mutate(story)}
onClick={() => save.mutate({ ...story, saved: isSaved })}
>
<Bookmark
className="size-4"
fill={story.saved ? "currentColor" : "none"}
fill={isSaved ? "currentColor" : "none"}
/>
{story.saved ? "Saved" : "Save"}
{isSaved ? "Saved" : "Save"}
</Button>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/digg-clone/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 isolate z-50 bg-black/50 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion apps/digg-clone/src/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "item-aligned",
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
Expand Down
6 changes: 6 additions & 0 deletions apps/digg-clone/src/queries/stories.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useSavedStore } from "@/store/saved-store"
import type {
FeedId,
FeedResponse,
Expand Down Expand Up @@ -95,9 +96,14 @@ export function useVoteStory() {

export function useToggleSaveStory() {
const qc = useQueryClient()
const syncSaved = useSavedStore((state) => state.sync)
return useMutation({
mutationFn: (story: Story) => api.toggleSave(story),
onSuccess: (story) => {
// The feed API runs on stateless serverless instances, so the saved flag
// set here cannot be relied upon for the "saved" feed. Persist the saved
// set client-side so the Saved view is always populated correctly.
syncSaved(story)
patchStoryCaches(qc, story)
},
})
Expand Down
54 changes: 54 additions & 0 deletions apps/digg-clone/src/store/saved-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { create } from "zustand"
import type { Story } from "@/lib/types"

const STORAGE_KEY = "digg.saved.stories"

interface SavedState {
/** Saved stories keyed by id, persisted to localStorage. */
stories: Record<string, Story>
isSaved: (id: string) => boolean
/** Reconcile a story's saved flag with the server response. */
sync: (story: Story) => void
list: () => Story[]
}

function read(): Record<string, Story> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, Story>) : {}
} catch {
return {}
}
}

function persist(stories: Record<string, Story>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stories))
} catch {
// ignore quota / unavailable storage
}
}

export const useSavedStore = create<SavedState>((set, get) => ({
stories: read(),

isSaved: (id) => Boolean(get().stories[id]),

sync: (story) =>
set((state) => {
const next = { ...state.stories }
if (story.saved) {
next[story.id] = { ...story, saved: true }
} else {
delete next[story.id]
}
persist(next)
return { stories: next }
}),

list: () =>
Object.values(get().stories).sort(
(a, b) =>
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
),
}))
3 changes: 3 additions & 0 deletions apps/github-clone/.replay/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"qa-project-id": "proj-exampleapps-pr-8-github-clone-mqfn4ftg"
}
Loading
Loading