refactor(core): overhaul state transition architecture, persistent job queue, and API layer
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s

- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
2026-09-01 00:41:05 -07:00
parent 271a542196
commit ebcaa5d72a
38 changed files with 3748 additions and 1202 deletions
@@ -23,6 +23,8 @@ interface DesktopArticleListProps {
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
feeds?: Feed[];
categories?: Category[];
stats?: import('../../types').Stats | null;
totalEntries?: number;
onFilterChange?: (newFilter: Partial<FilterState>) => void;
}
@@ -52,6 +54,8 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
onToggleStar,
feeds = [],
categories = [],
stats,
totalEntries = 0,
onFilterChange,
}) => {
const getFilterTitle = () => {
@@ -70,7 +74,35 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
return 'Inbox';
};
const unreadCount = entries.filter((e) => !e.is_read).length;
// Calculate accurate unread and total counts from feeds/categories/stats
const getFilterCounts = () => {
if (filter.feedId) {
const activeFeed = feeds.find((f) => f.id === filter.feedId);
return {
unread: activeFeed?.unread_count || 0,
total: activeFeed?.total_count || totalEntries || 0,
};
}
if (filter.categoryId) {
const catFeeds = feeds.filter((f) => f.category_id === filter.categoryId);
const unread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
const total = catFeeds.reduce((acc, f) => acc + (f.total_count || 0), 0);
return { unread, total: total || totalEntries || 0 };
}
if (filter.view === 'starred') {
return { unread: 0, total: stats?.total_starred || totalEntries || 0 };
}
if (filter.view === 'all') {
return { unread: stats?.total_unread || 0, total: totalEntries || 0 };
}
if (filter.view === 'shared') {
return { unread: 0, total: stats?.total_shared || totalEntries || 0 };
}
// 'inbox'
return { unread: stats?.total_unread || 0, total: stats?.total_unread || totalEntries || 0 };
};
const { unread: trueUnreadCount, total: trueTotalCount } = getFilterCounts();
return (
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
@@ -80,7 +112,21 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
<span className="list-title-text" title={getFilterTitle()}>
{getFilterTitle()}
</span>
{unreadCount > 0 && <span className="badge badge-unread">{unreadCount}</span>}
{filter.view === 'inbox' ? (
trueUnreadCount > 0 && <span className="badge badge-unread">{trueUnreadCount}</span>
) : filter.view === 'all' ? (
trueTotalCount > 0 && (
<span className="badge badge-unread" style={{ background: 'rgba(148, 163, 184, 0.15)', color: 'var(--text-secondary)' }}>
{trueTotalCount}
</span>
)
) : filter.view === 'starred' ? (
trueTotalCount > 0 && (
<span className="badge badge-unread" style={{ background: 'rgba(234, 179, 8, 0.15)', color: 'var(--accent-star)' }}>
{trueTotalCount}
</span>
)
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
@@ -104,7 +150,7 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
</div>
)}
{unreadCount > 0 && (
{trueUnreadCount > 0 && (
<button
className="btn-icon"
onClick={onMarkAllRead}
+5 -1
View File
@@ -226,7 +226,11 @@ export const DesktopReader: React.FC<DesktopReaderProps> = ({
setIsSharing(true);
try {
const res = await api.toggleShare(entry.id);
onUpdateEntry?.(res.entry);
const updatedEntry = {
...res.entry,
share_uuid: res.share_uuid ?? null,
};
onUpdateEntry?.(updatedEntry);
if (res.shared && res.share_uuid) {
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
navigator.clipboard.writeText(shareUrl);
@@ -18,6 +18,7 @@ interface ThreePaneViewProps {
onMarkAllRead: () => void;
onUpdateEntry?: (entry: FeedEntry) => void;
stats: Stats | null;
totalEntries?: number;
onOpenSettings: () => void;
onOpenAddFeed: () => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
@@ -38,6 +39,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
onMarkAllRead,
onUpdateEntry,
stats,
totalEntries = 0,
onOpenSettings,
onOpenAddFeed,
onPlayAudio,
@@ -115,6 +117,8 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
onToggleStar={onToggleStar}
feeds={feeds}
categories={categories}
stats={stats}
totalEntries={totalEntries}
onFilterChange={onFilterChange}
/>
@@ -201,7 +201,11 @@ export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
try {
const res = await api.toggleShare(entry.id);
if (res && res.entry) {
onUpdateEntry?.(res.entry);
const updatedEntry = {
...res.entry,
share_uuid: res.share_uuid ?? null,
};
onUpdateEntry?.(updatedEntry);
}
if (res && res.shared && res.share_uuid) {
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;