diff --git a/app/scratchpad/[slug]/page.tsx b/app/scratchpad/[slug]/page.tsx new file mode 100644 index 0000000..838f0cb --- /dev/null +++ b/app/scratchpad/[slug]/page.tsx @@ -0,0 +1,73 @@ +import { MarkdocRenderer } from 'components/markdoc-renderer' +import { getScratchpadEntry } from 'data/scratchpad.dto' +import { getLogger } from 'lib/logger' +import { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' + +type Params = { + params: Promise<{ + slug: string + }> +} + +const log = getLogger() +export const generateStaticParams = () => [] + +export async function generateMetadata(props: Params): Promise { + const { slug } = await props.params + const entryData = await getScratchpadEntry(slug) + + if (!entryData) { + log.error(`Scratchpad entry not found for slug: ${slug}`) + return {} + } + + const { formattedDate } = entryData + const title = `Scratchpad - ${formattedDate}` + + return { + title, + description: 'A scratchpad note', + openGraph: { + title, + description: 'A scratchpad note', + images: [ + { + url: 'https://www.chrisjarling.com/og.jpg', + secureUrl: 'https://www.chrisjarling.com/og.jpg', + width: 1200, + height: 630, + }, + ], + }, + } +} + +export default async function ScratchpadEntry(props: Params) { + const { slug } = await props.params + const entryData = await getScratchpadEntry(slug) + + if (!entryData) { + notFound() + } + + const { formattedDate, renderableContent } = entryData + + return ( +
+
+ + ← Back to scratchpad + +

+ {formattedDate} +

+
+ +
+ ) +} diff --git a/app/scratchpad/page.tsx b/app/scratchpad/page.tsx new file mode 100644 index 0000000..ea81f70 --- /dev/null +++ b/app/scratchpad/page.tsx @@ -0,0 +1,53 @@ +import { LoadMoreButton } from 'components/load-more-button' +import { ScratchpadFeedItem } from 'components/scratchpad-feed-item' +import { getPaginatedScratchpad } from 'data/scratchpad.dto' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Scratchpad - Chris Jarling', + description: 'Quick notes and thoughts', +} + +type SearchParams = Promise<{ + page?: string +}> + +export default async function ScratchpadPage({ + searchParams, +}: { + searchParams: SearchParams +}) { + const params = await searchParams + const page = parseInt(params.page || '1', 10) + const { entries, pagination } = await getPaginatedScratchpad(page) + + return ( +
+

Scratchpad

+ + {entries.length === 0 ? ( +

No entries yet.

+ ) : ( + <> +
+ {entries.map((entry) => ( + + ))} +
+ + {pagination.hasNextPage && ( + + )} + + )} +
+ ) +} diff --git a/components/__tests__/load-more-button.test.tsx b/components/__tests__/load-more-button.test.tsx new file mode 100644 index 0000000..11b8343 --- /dev/null +++ b/components/__tests__/load-more-button.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { LoadMoreButton } from '../load-more-button' + +describe('LoadMoreButton', () => { + it('renders a link with the correct href', () => { + render() + + const link = screen.getByRole('link', { name: /load more/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/scratchpad?page=2') + }) + + it('displays "Load more" text', () => { + render() + + expect(screen.getByText('Load more')).toBeInTheDocument() + }) + + it('renders with the correct next page number', () => { + render() + + const link = screen.getByRole('link', { name: /load more/i }) + expect(link).toHaveAttribute('href', '/articles?page=5') + }) + + it('includes an arrow down icon', () => { + const { container } = render( + , + ) + + // ArrowDownIcon should be rendered + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) +}) diff --git a/components/load-more-button.tsx b/components/load-more-button.tsx new file mode 100644 index 0000000..b1eea2d --- /dev/null +++ b/components/load-more-button.tsx @@ -0,0 +1,21 @@ +import { ArrowDownIcon } from '@heroicons/react/24/outline' +import Link from 'next/link' + +type LoadMoreButtonProps = { + nextPage: number + basePath: string +} + +export function LoadMoreButton({ nextPage, basePath }: LoadMoreButtonProps) { + return ( +
+ + Load more + + +
+ ) +} diff --git a/components/scratchpad-feed-item.tsx b/components/scratchpad-feed-item.tsx new file mode 100644 index 0000000..7070712 --- /dev/null +++ b/components/scratchpad-feed-item.tsx @@ -0,0 +1,37 @@ +import Markdoc, { Node } from '@markdoc/markdoc' +import { format, parseISO } from 'date-fns' +import Link from 'next/link' + +import { MarkdocRenderer } from './markdoc-renderer' + +type ScratchpadFeedItemProps = { + slug: string + timestamp: string + content: () => Promise<{ node: Node }> +} + +export async function ScratchpadFeedItem({ + slug, + timestamp, + content, +}: ScratchpadFeedItemProps) { + const parsedTimestamp = parseISO(timestamp) + const formattedDate = format(parsedTimestamp, "do LLL, yyyy 'at' HH:mm") + + const { node } = await content() + const renderableContent = Markdoc.transform(node) + + return ( +
+ + {formattedDate} + +
+ +
+
+ ) +} diff --git a/content/scratchpad/basic-ralph-loops.md b/content/scratchpad/basic-ralph-loops.md new file mode 100644 index 0000000..4054f3d --- /dev/null +++ b/content/scratchpad/basic-ralph-loops.md @@ -0,0 +1,40 @@ +--- +id: basic-ralph-loops +timestamp: '2026-02-07T15:30:00.000Z' +--- + +Basic Ralph loops using claude: + +Setup: + +- plan.md: a plan of what to build, for reference +- tasks.toon: a list of small tasks that agents can work on +- progress.txt: cross-agent memory +- prompot.md: The prompt passed into claude + +Basic loop: + +```bash +while true; cat plans/prompt.md | claude --permission-mode acceptEdits; end +``` + +with prompt.md + +``` +- Read tasks.toon +- Read progress.txt +- Familiarize yourself with the codebase. + +Afterwards, pick the most important next thing to work in in the tasks.md file. +If all work is done or you require input to keep working on any task, notify the user. + +IMPORTANT: + +- If there are no tests for a feature yet, write them first and run them to ensure they fail +- After doing your work, ensure tests are passing +- Only work at one task at a time +- After finishing a taks, add a note to plans/progress.txt +- After you are done with one task, you must shut this instance of yourself down. You are being run in a loop and a fresh instance will spawn right away to work on the next task. Get your own pid and end it so the next iteration can start with a fresh context. +``` + +Works well, but instances don't shut down automatically, have to close manually after every task. `-p` flag works better, but lose live output to examine. With `-p`, also want max iterations likely. diff --git a/data/__tests__/scratchpad.dto.test.ts b/data/__tests__/scratchpad.dto.test.ts new file mode 100644 index 0000000..2f54766 --- /dev/null +++ b/data/__tests__/scratchpad.dto.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' + +// Mock the cms module before importing anything that uses it +vi.mock('../cms', () => ({ + cms: { + scratchpad: { + all: vi.fn(), + get: vi.fn(), + }, + }, +})) + +// Mock keystatic config to prevent import errors +vi.mock('../../keystatic.config', () => ({ + default: {}, +})) + +// Mock Markdoc to avoid validation issues in tests +vi.mock('@markdoc/markdoc', () => ({ + default: { + validate: vi.fn().mockReturnValue([]), + transform: vi.fn().mockReturnValue({ type: 'div', children: [] }), + }, +})) + +import { cms } from '../cms' +import { + getPaginatedScratchpad, + getScratchpadEntry, + ScratchpadEntries, +} from '../scratchpad.dto' + +describe('getScratchpadEntry', () => { + it('returns null when entry does not exist', async () => { + vi.mocked(cms.scratchpad.get).mockResolvedValue(null) + + const result = await getScratchpadEntry('non-existent-slug') + + expect(result).toBeNull() + expect(cms.scratchpad.get).toHaveBeenCalledWith('non-existent-slug') + }) + + it('returns entry data when entry exists', async () => { + const mockEntry = { + id: 'sample-entry-slug', + timestamp: '2024-01-15T14:30:00Z', + content: vi.fn().mockResolvedValue({ + node: { + name: 'document', + attributes: {}, + children: [], + }, + }), + } + + vi.mocked(cms.scratchpad.get).mockResolvedValue(mockEntry) + + const result = await getScratchpadEntry('sample-entry-slug') + + expect(result).not.toBeNull() + expect(result?.timestamp).toBe('2024-01-15T14:30:00Z') + expect(result?.formattedDate).toContain('15th Jan, 2024') + expect(result?.renderableContent).toBeDefined() + }) +}) + +describe('getPaginatedScratchpad', () => { + it('returns first page with 10 entries when page is 1', async () => { + const mockEntries = Array.from({ length: 15 }, (_, i) => ({ + slug: `entry-${15 - i}`, + entry: { + id: `entry-${15 - i}`, + timestamp: `2024-01-${String(15 - i).padStart(2, '0')}T10:00:00Z`, + content: vi.fn(), + }, + })) + + vi.mocked(cms.scratchpad.all).mockResolvedValue( + mockEntries as ScratchpadEntries, + ) + + const result = await getPaginatedScratchpad(1) + + expect(result.entries).toHaveLength(10) + expect(result.pagination.currentPage).toBe(1) + expect(result.pagination.totalPages).toBe(2) + expect(result.pagination.hasNextPage).toBe(true) + }) + + it('returns second page with remaining entries', async () => { + const mockEntries = Array.from({ length: 15 }, (_, i) => ({ + slug: `entry-${15 - i}`, + entry: { + id: `entry-${15 - i}`, + timestamp: `2024-01-${String(15 - i).padStart(2, '0')}T10:00:00Z`, + content: vi.fn(), + }, + })) + + vi.mocked(cms.scratchpad.all).mockResolvedValue( + mockEntries as ScratchpadEntries, + ) + + const result = await getPaginatedScratchpad(2) + + expect(result.entries).toHaveLength(5) + expect(result.pagination.currentPage).toBe(2) + expect(result.pagination.totalPages).toBe(2) + expect(result.pagination.hasNextPage).toBe(false) + }) + + it('sorts entries by timestamp descending (newest first)', async () => { + const mockEntries = [ + { + slug: 'entry-one', + entry: { + id: 'entry-one', + timestamp: '2024-01-10T10:00:00Z', + content: vi.fn(), + }, + }, + { + slug: 'entry-two', + entry: { + id: 'entry-two', + timestamp: '2024-01-15T10:00:00Z', + content: vi.fn(), + }, + }, + { + slug: 'entry-three', + entry: { + id: 'entry-three', + timestamp: '2024-01-12T10:00:00Z', + content: vi.fn(), + }, + }, + ] + + vi.mocked(cms.scratchpad.all).mockResolvedValue( + mockEntries as ScratchpadEntries, + ) + + const result = await getPaginatedScratchpad(1) + + expect(result.entries[0].slug).toBe('entry-two') + expect(result.entries[1].slug).toBe('entry-three') + expect(result.entries[2].slug).toBe('entry-one') + }) + + it('returns empty array when page exceeds available pages', async () => { + const mockEntries = Array.from({ length: 5 }, (_, i) => ({ + slug: `entry-${5 - i}`, + entry: { + id: `entry-${5 - i}`, + timestamp: `2024-01-${String(5 - i).padStart(2, '0')}T10:00:00Z`, + content: vi.fn(), + }, + })) + + vi.mocked(cms.scratchpad.all).mockResolvedValue( + mockEntries as ScratchpadEntries, + ) + + const result = await getPaginatedScratchpad(5) + + expect(result.entries).toHaveLength(0) + expect(result.pagination.hasNextPage).toBe(false) + }) + + it('handles empty scratchpad', async () => { + vi.mocked(cms.scratchpad.all).mockResolvedValue([]) + + const result = await getPaginatedScratchpad(1) + + expect(result.entries).toHaveLength(0) + expect(result.pagination.totalPages).toBe(0) + expect(result.pagination.hasNextPage).toBe(false) + }) +}) diff --git a/data/cms.ts b/data/cms.ts index 0559f54..31a3bcc 100644 --- a/data/cms.ts +++ b/data/cms.ts @@ -61,4 +61,20 @@ export const cms = { return book }, }, + scratchpad: { + async all() { + const entries = await reader.collections.scratchpad.all() + + return entries + }, + async get(slug: string) { + const entry = await reader.collections.scratchpad.read(slug) + + if (!entry) { + return null + } + + return entry + }, + }, } diff --git a/data/scratchpad.dto.ts b/data/scratchpad.dto.ts new file mode 100644 index 0000000..d9760c3 --- /dev/null +++ b/data/scratchpad.dto.ts @@ -0,0 +1,69 @@ +import Markdoc from '@markdoc/markdoc' +import { format, parseISO } from 'date-fns' +import { getLogger } from 'lib/logger' + +import { cms } from './cms' + +const log = getLogger() + +const ENTRIES_PER_PAGE = 10 + +export type ScratchpadEntries = Awaited> +export type ScratchpadEntry = NonNullable< + Awaited> +> + +export async function getPaginatedScratchpad(page: number = 1) { + const allEntries = await cms.scratchpad.all() + + // Sort by timestamp descending - newest first + const sortedEntries = [...allEntries].sort((a, b) => { + return b.entry.timestamp.localeCompare(a.entry.timestamp) + }) + + // Calculate pagination + const totalEntries = sortedEntries.length + const totalPages = Math.ceil(totalEntries / ENTRIES_PER_PAGE) + const startIndex = (page - 1) * ENTRIES_PER_PAGE + const endIndex = startIndex + ENTRIES_PER_PAGE + + // Get entries for current page + const entries = sortedEntries.slice(startIndex, endIndex) + + return { + entries, + pagination: { + currentPage: page, + totalPages, + totalEntries, + hasNextPage: page < totalPages, + }, + } +} + +export async function getScratchpadEntry(slug: string) { + const entry = await cms.scratchpad.get(slug) + + if (!entry) { + return null + } + + // Parse timestamp from entry + const timestamp = parseISO(entry.timestamp) + const formattedDate = format(timestamp, "do LLL, yyyy 'at' HH:mm") + + const { node } = await entry.content() + const errors = Markdoc.validate(node) + if (errors.length) { + log.error(errors.join(',')) + return null + } + + const renderableContent = Markdoc.transform(node) + + return { + timestamp: entry.timestamp, + formattedDate, + renderableContent, + } +} diff --git a/keystatic.config.ts b/keystatic.config.ts index 98b51be..a59a89e 100644 --- a/keystatic.config.ts +++ b/keystatic.config.ts @@ -113,5 +113,22 @@ export default config({ content: fields.markdoc({ label: 'Notes', extension: 'md' }), }, }), + scratchpad: collection({ + label: 'Scratchpad', + slugField: 'id', + path: 'content/scratchpad/*', + format: { contentField: 'content' }, + schema: { + id: fields.slug({ + name: { label: 'ID' }, + slug: { label: 'Slug' }, + }), + timestamp: fields.text({ + label: 'Timestamp', + validation: { isRequired: true }, + }), + content: fields.markdoc({ label: 'Content', extension: 'md' }), + }, + }), }, }) diff --git a/vitest.config.ts b/vitest.config.ts index beca723..0c14429 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ components: resolve(__dirname, './components'), '@components': resolve(__dirname, './components'), lib: resolve(__dirname, './lib'), + 'keystatic.config': resolve(__dirname, './keystatic.config.ts'), }, }, })