summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 05:45:41 -0800
committerFuwn <[email protected]>2026-02-07 05:45:41 -0800
commit6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae (patch)
treec8b583a21bd489170b8f664e8c028fbbd9d95d49
parentfix: resolve 6 pre-ship audit bugs (diff)
downloadasa.news-6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae.tar.xz
asa.news-6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae.zip
feat: resolve 7 pre-ship QoL items
- Space/Shift+Space: page down/up in detail panel (80% scroll) - Content font: sans-serif/serif/monospace selector in appearance settings, applied to article content in detail panel - Accessibility: entry-list-item uses button instead of div, folder toggles have aria-expanded, shortcut keys have aria-labels - Share notes: replaced window.prompt with proper modal dialog matching existing UI patterns - Worker .env.example: template with all 10 environment variables - Worker poisoned messages: archive unprocessable queue messages instead of leaving them stuck forever - Worker pool Submit: check return value, reschedule dropped feeds 30s into the future, log warnings for rejected submissions
-rw-r--r--apps/web/app/reader/_components/entry-detail-panel.tsx92
-rw-r--r--apps/web/app/reader/_components/entry-list-item.tsx7
-rw-r--r--apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx1
-rw-r--r--apps/web/app/reader/_components/sidebar-content.tsx1
-rw-r--r--apps/web/app/reader/settings/_components/appearance-settings.tsx23
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts14
-rw-r--r--apps/web/lib/stores/user-interface-store.ts8
-rw-r--r--services/worker/.env.example29
-rw-r--r--services/worker/internal/scheduler/scheduler.go78
9 files changed, 242 insertions, 11 deletions
diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx
index 5825b1e..4d15e1f 100644
--- a/apps/web/app/reader/_components/entry-detail-panel.tsx
+++ b/apps/web/app/reader/_components/entry-detail-panel.tsx
@@ -68,6 +68,14 @@ export function EntryDetailPanel({
const showReadingTime = useUserInterfaceStore(
(state) => state.showReadingTime
)
+ const contentFont = useUserInterfaceStore((state) => state.contentFont)
+
+ const contentFontClass =
+ contentFont === "serif"
+ ? "font-serif"
+ : contentFont === "monospace"
+ ? "font-mono"
+ : "font-sans"
const proseContainerReference = useRef<HTMLDivElement>(null)
const [selectionToolbarState, setSelectionToolbarState] = useState<{
@@ -82,6 +90,9 @@ export function EntryDetailPanel({
containerRect: DOMRect
} | null>(null)
const [unpositionedHighlights, setUnpositionedHighlights] = useState<Highlight[]>([])
+ const [isShareNoteDialogOpen, setIsShareNoteDialogOpen] = useState(false)
+ const [shareNoteText, setShareNoteText] = useState("")
+ const shareNoteTextareaReference = useRef<HTMLTextAreaElement>(null)
const { data: timelineData } = useTimeline()
const currentEntry = timelineData?.pages
@@ -270,6 +281,32 @@ export function EntryDetailPanel({
return () => container.removeEventListener("click", handleMarkClick)
}, [highlightsData])
+ useEffect(() => {
+ if (isShareNoteDialogOpen) {
+ setTimeout(() => shareNoteTextareaReference.current?.focus(), 0)
+ }
+ }, [isShareNoteDialogOpen])
+
+ useEffect(() => {
+ if (!isShareNoteDialogOpen) return
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ event.stopPropagation()
+ setIsShareNoteDialogOpen(false)
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown, true)
+ return () => document.removeEventListener("keydown", handleKeyDown, true)
+ }, [isShareNoteDialogOpen])
+
+ function handleShareConfirm() {
+ shareMutation.mutate(shareNoteText.trim() || null)
+ setIsShareNoteDialogOpen(false)
+ }
+
function handleCreateHighlight(note: string | null) {
const container = proseContainerReference.current
if (!container || !selectionToolbarState) return
@@ -382,8 +419,8 @@ export function EntryDetailPanel({
<button
type="button"
onClick={() => {
- const note = window.prompt("add a note (optional):")
- shareMutation.mutate(note || null)
+ setShareNoteText("")
+ setIsShareNoteDialogOpen(true)
}}
className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary"
>
@@ -461,7 +498,7 @@ export function EntryDetailPanel({
<div className="relative">
<div
ref={proseContainerReference}
- className="prose-reader text-text-secondary"
+ className={`prose-reader text-text-secondary ${contentFontClass}`}
/>
{selectionToolbarState && (
<HighlightSelectionToolbar
@@ -484,6 +521,55 @@ export function EntryDetailPanel({
)}
</div>
</article>
+ {isShareNoteDialogOpen && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
+ <div
+ className="fixed inset-0 bg-background-primary/80"
+ onClick={() => setIsShareNoteDialogOpen(false)}
+ />
+ <div className="relative w-full max-w-md border border-border bg-background-secondary p-6">
+ <h2 className="mb-4 text-text-primary">share entry</h2>
+ <form
+ onSubmit={(event) => {
+ event.preventDefault()
+ handleShareConfirm()
+ }}
+ className="space-y-4"
+ >
+ <div className="space-y-2">
+ <label htmlFor="share-note-textarea" className="text-text-secondary">
+ add a note (optional)
+ </label>
+ <textarea
+ ref={shareNoteTextareaReference}
+ id="share-note-textarea"
+ value={shareNoteText}
+ onChange={(event) => setShareNoteText(event.target.value)}
+ rows={4}
+ placeholder="Write a note to accompany this shared entry..."
+ className="w-full resize-y border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ </div>
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setIsShareNoteDialogOpen(false)}
+ className="flex-1 border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary"
+ >
+ cancel
+ </button>
+ <button
+ type="submit"
+ disabled={shareMutation.isPending}
+ className="flex-1 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {shareMutation.isPending ? "sharing..." : "share"}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
</div>
)
}
diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx
index d192081..1669658 100644
--- a/apps/web/app/reader/_components/entry-list-item.tsx
+++ b/apps/web/app/reader/_components/entry-list-item.tsx
@@ -45,12 +45,13 @@ export function EntryListItem({
const displayTitle = entry.customTitle ?? entry.feedTitle
return (
- <div
+ <button
+ type="button"
ref={measureReference}
data-index={virtualItem.index}
onClick={onSelect}
className={classNames(
- "absolute left-0 top-0 w-full cursor-pointer border-b border-border px-4 transition-colors",
+ "absolute left-0 top-0 w-full cursor-pointer border-b border-border bg-transparent px-4 text-left font-[inherit] text-[inherit] transition-colors",
isSelected
? "bg-background-tertiary"
: isFocused
@@ -130,6 +131,6 @@ export function EntryListItem({
)}
</div>
)}
- </div>
+ </button>
)
}
diff --git a/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx
index 139249b..f6e95eb 100644
--- a/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx
+++ b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx
@@ -110,6 +110,7 @@ export function KeyboardShortcutsDialog() {
{shortcut.keys.map((key) => (
<kbd
key={key}
+ aria-label={`${key}: ${shortcut.description}`}
className="border border-border bg-background-secondary px-1.5 py-0.5 font-mono text-[0.75rem] text-text-primary"
>
{key}
diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx
index e13926d..17d07bb 100644
--- a/apps/web/app/reader/_components/sidebar-content.tsx
+++ b/apps/web/app/reader/_components/sidebar-content.tsx
@@ -279,6 +279,7 @@ export function SidebarContent() {
>
<button
type="button"
+ aria-expanded={isExpanded}
onClick={() =>
toggleFolderExpansion(folder.folderIdentifier)
}
diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx
index 6c04f00..508051f 100644
--- a/apps/web/app/reader/settings/_components/appearance-settings.tsx
+++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx
@@ -31,6 +31,10 @@ export function AppearanceSettings() {
)
const fontSize = useUserInterfaceStore((state) => state.fontSize)
const setFontSize = useUserInterfaceStore((state) => state.setFontSize)
+ const contentFont = useUserInterfaceStore((state) => state.contentFont)
+ const setContentFont = useUserInterfaceStore(
+ (state) => state.setContentFont
+ )
const timeDisplayFormat = useUserInterfaceStore(
(state) => state.timeDisplayFormat
)
@@ -155,6 +159,25 @@ export function AppearanceSettings() {
</select>
</div>
<div className="mb-6">
+ <h3 className="mb-2 text-text-primary">content font</h3>
+ <p className="mb-3 text-text-dim">
+ controls the typeface used for article content
+ </p>
+ <select
+ value={contentFont}
+ onChange={(event) =>
+ setContentFont(
+ event.target.value as "sans-serif" | "serif" | "monospace"
+ )
+ }
+ className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim"
+ >
+ <option value="sans-serif">sans-serif</option>
+ <option value="serif">serif</option>
+ <option value="monospace">monospace</option>
+ </select>
+ </div>
+ <div className="mb-6">
<h3 className="mb-2 text-text-primary">time display</h3>
<p className="mb-3 text-text-dim">
choose between relative timestamps (e.g. &ldquo;2h ago&rdquo;) or
diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts
index 24a4761..8685396 100644
--- a/apps/web/lib/hooks/use-keyboard-navigation.ts
+++ b/apps/web/lib/hooks/use-keyboard-navigation.ts
@@ -368,6 +368,7 @@ export function useKeyboardNavigation() {
function handleDetailPanelKeyDown(event: KeyboardEvent) {
const SCROLL_AMOUNT = 100
+ const PAGE_SCROLL_FRACTION = 0.8
const detailArticle = document.querySelector<HTMLElement>(
"[data-detail-article]"
)
@@ -385,6 +386,19 @@ export function useKeyboardNavigation() {
if (detailArticle) detailArticle.scrollTop -= SCROLL_AMOUNT
break
}
+ case " ": {
+ event.preventDefault()
+ if (detailArticle) {
+ const pageScrollAmount =
+ detailArticle.clientHeight * PAGE_SCROLL_FRACTION
+ if (event.shiftKey) {
+ detailArticle.scrollTop -= pageScrollAmount
+ } else {
+ detailArticle.scrollTop += pageScrollAmount
+ }
+ }
+ break
+ }
case "?": {
event.preventDefault()
toggleShortcutsDialog()
diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts
index 01dceba..fdee347 100644
--- a/apps/web/lib/stores/user-interface-store.ts
+++ b/apps/web/lib/stores/user-interface-store.ts
@@ -7,6 +7,8 @@ type DisplayDensity = "compact" | "default" | "spacious"
type FontSize = "small" | "default" | "large"
+type ContentFont = "sans-serif" | "serif" | "monospace"
+
type TimeDisplayFormat = "relative" | "absolute"
type FocusedPanel = "sidebar" | "entryList" | "detailPanel"
@@ -38,6 +40,7 @@ interface UserInterfaceState {
showFeedFavicons: boolean
focusFollowsInteraction: boolean
fontSize: FontSize
+ contentFont: ContentFont
timeDisplayFormat: TimeDisplayFormat
showEntryImages: boolean
showReadingTime: boolean
@@ -62,6 +65,7 @@ interface UserInterfaceState {
setShowFeedFavicons: (show: boolean) => void
setFocusFollowsInteraction: (enabled: boolean) => void
setFontSize: (size: FontSize) => void
+ setContentFont: (font: ContentFont) => void
setTimeDisplayFormat: (format: TimeDisplayFormat) => void
setShowEntryImages: (show: boolean) => void
setShowReadingTime: (show: boolean) => void
@@ -90,6 +94,7 @@ export const useUserInterfaceStore = create<UserInterfaceState>()(
showFeedFavicons: true,
focusFollowsInteraction: false,
fontSize: "default",
+ contentFont: "sans-serif",
timeDisplayFormat: "relative",
showEntryImages: true,
showReadingTime: true,
@@ -134,6 +139,8 @@ export const useUserInterfaceStore = create<UserInterfaceState>()(
setFontSize: (size) => set({ fontSize: size }),
+ setContentFont: (font) => set({ contentFont: font }),
+
setTimeDisplayFormat: (format) => set({ timeDisplayFormat: format }),
setShowEntryImages: (show) => set({ showEntryImages: show }),
@@ -187,6 +194,7 @@ export const useUserInterfaceStore = create<UserInterfaceState>()(
focusFollowsInteraction: state.focusFollowsInteraction,
expandedFolderIdentifiers: state.expandedFolderIdentifiers,
fontSize: state.fontSize,
+ contentFont: state.contentFont,
timeDisplayFormat: state.timeDisplayFormat,
showEntryImages: state.showEntryImages,
showReadingTime: state.showReadingTime,
diff --git a/services/worker/.env.example b/services/worker/.env.example
new file mode 100644
index 0000000..4a4a706
--- /dev/null
+++ b/services/worker/.env.example
@@ -0,0 +1,29 @@
+# Database connection string (required)
+DATABASE_URL=postgresql://user:password@localhost:5432/asa_news
+
+# Number of concurrent workers processing feed updates
+WORKER_CONCURRENCY=10
+
+# Interval for polling the job queue (duration string, e.g., 30s, 5m)
+POLL_INTERVAL=30s
+
+# Timeout for individual feed fetch operations
+FETCH_TIMEOUT=30s
+
+# Interval for checking the task queue
+QUEUE_POLL_INTERVAL=5s
+
+# Number of entries to process per batch
+BATCH_SIZE=50
+
+# Port for health check HTTP server
+HEALTH_PORT=8080
+
+# Encryption key for sensitive vault credentials (optional)
+ENCRYPTION_KEY=
+
+# Log level: debug, info, warn, error
+LOG_LEVEL=info
+
+# Enable JSON structured logging output
+LOG_JSON=false
diff --git a/services/worker/internal/scheduler/scheduler.go b/services/worker/internal/scheduler/scheduler.go
index 646e263..19023e1 100644
--- a/services/worker/internal/scheduler/scheduler.go
+++ b/services/worker/internal/scheduler/scheduler.go
@@ -104,7 +104,7 @@ func (feedScheduler *Scheduler) executePollCycle(cycleContext context.Context) {
for _, claimedFeed := range claimedFeeds {
capturedFeed := claimedFeed
- feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) {
+ submitted := feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) {
ProcessRefreshRequest(
workContext,
capturedFeed,
@@ -115,6 +115,16 @@ func (feedScheduler *Scheduler) executePollCycle(cycleContext context.Context) {
feedScheduler.logger,
)
})
+
+ if !submitted {
+ feedScheduler.logger.Warn(
+ "failed to submit feed refresh to worker pool, rescheduling",
+ "feed_identifier", capturedFeed.Identifier,
+ "feed_url", capturedFeed.URL,
+ )
+
+ feedScheduler.rescheduleDroppedFeed(cycleContext, capturedFeed)
+ }
}
}
@@ -134,7 +144,13 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context)
unmarshalError := json.Unmarshal(queueMessage.Message, &refreshRequest)
if unmarshalError != nil {
- feedScheduler.logger.Error("failed to unmarshal refresh request", "error", unmarshalError)
+ feedScheduler.logger.Error(
+ "failed to unmarshal refresh request, archiving poisoned message",
+ "message_identifier", queueMessage.MsgID,
+ "error", unmarshalError,
+ )
+
+ feedScheduler.archivePoisonedMessage(cycleContext, queueMessage.MsgID)
return
}
@@ -143,17 +159,20 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context)
if lookupError != nil {
feedScheduler.logger.Error(
- "failed to look up feed for queue request",
+ "failed to look up feed for queue request, archiving unresolvable message",
+ "message_identifier", queueMessage.MsgID,
"feed_identifier", refreshRequest.FeedIdentifier,
"error", lookupError,
)
+ feedScheduler.archivePoisonedMessage(cycleContext, queueMessage.MsgID)
+
return
}
capturedMessageIdentifier := queueMessage.MsgID
- feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) {
+ submitted := feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) {
ProcessRefreshRequest(
workContext,
feed,
@@ -169,11 +188,20 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context)
if archiveError != nil {
feedScheduler.logger.Error(
"failed to archive queue message",
- "message_id", capturedMessageIdentifier,
+ "message_identifier", capturedMessageIdentifier,
"error", archiveError,
)
}
})
+
+ if !submitted {
+ feedScheduler.logger.Warn(
+ "failed to submit queue refresh to worker pool, message will retry after visibility timeout",
+ "message_identifier", capturedMessageIdentifier,
+ "feed_identifier", feed.Identifier,
+ "feed_url", feed.URL,
+ )
+ }
}
func (feedScheduler *Scheduler) claimDueFeeds(claimContext context.Context) ([]model.Feed, error) {
@@ -281,3 +309,43 @@ func (feedScheduler *Scheduler) lookupFeed(lookupContext context.Context, feedId
return feed, nil
}
+
+func (feedScheduler *Scheduler) archivePoisonedMessage(archiveContext context.Context, messageIdentifier int64) {
+ _, archiveError := pgmq.Archive(archiveContext, feedScheduler.databaseConnectionPool, "feed_refresh", messageIdentifier)
+
+ if archiveError != nil {
+ feedScheduler.logger.Error(
+ "failed to archive poisoned queue message",
+ "message_identifier", messageIdentifier,
+ "error", archiveError,
+ )
+ }
+}
+
+const droppedFeedRescheduleDelay = 30 * time.Second
+
+func (feedScheduler *Scheduler) rescheduleDroppedFeed(rescheduleContext context.Context, droppedFeed model.Feed) {
+ rescheduleQuery := `
+ UPDATE feeds
+ SET next_fetch_at = $1
+ WHERE id = $2
+ `
+
+ rescheduleTime := time.Now().UTC().Add(droppedFeedRescheduleDelay)
+
+ _, executeError := feedScheduler.databaseConnectionPool.Exec(
+ rescheduleContext,
+ rescheduleQuery,
+ rescheduleTime,
+ droppedFeed.Identifier,
+ )
+
+ if executeError != nil {
+ feedScheduler.logger.Error(
+ "failed to reschedule dropped feed",
+ "feed_identifier", droppedFeed.Identifier,
+ "feed_url", droppedFeed.URL,
+ "error", executeError,
+ )
+ }
+}