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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# ShadowBrain

[![Version](https://img.shields.io/badge/version-0.8.0-yellow)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.8.2-yellow)](CHANGELOG.md)

</div>

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shadowbrain",
"version": "0.8.0",
"version": "0.8.2",
"private": true,
"scripts": {
"dev": "next dev",
Expand All @@ -14,6 +14,7 @@
"test:db:reset": "node scripts/test-db-reset.js",
"test:db:cleanup": "node scripts/test-db-cleanup.js",
"import:markdown": "node --env-file=.env --import tsx scripts/import-markdown.ts",
"sync:docs": "node --env-file=.env --import tsx scripts/sync-docs.ts",
"migrate:journal-shadows": "node --env-file=.env --import tsx scripts/migrate-journal-shadows.ts",
"hash:password": "tsx scripts/hash-password.ts",
"test": "vitest",
Expand Down
126 changes: 126 additions & 0 deletions scripts/sync-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* CLI entry point for the docs sync system.
*
* Reads the `docs/` directory at the project root and upserts every
* `.md` file as a `content_item` with `type='note'` and
* `source='docs-sync'`, tagged `#project:shadowbrain`, `#docs`, and a
* path-derived category tag. Files removed from disk are pruned from the
* database. Re-running is safe and idempotent.
*
* Usage:
* pnpm sync:docs
* pnpm sync:docs --dir /path/to/docs
* pnpm sync:docs --force # re-write every file
* pnpm sync:docs --dry-run # preview without writing
*
* Exits non-zero when at least one file failed to sync, or when a
* transaction-level failure aborts the run. The summary is printed for
* per-file failures; a crash short-circuits to the error handler.
*/
import { resolve } from "path";
import { getDb, closeDb } from "@/db/index";
import { syncDocsDirectory, formatDocsSyncResult } from "@/lib/docs-sync";
import { log } from "@/lib/logger";

interface CliArgs {
dir: string;
force: boolean;
dryRun: boolean;
}

function parseArgs(argv: string[]): CliArgs {
let dir: string | null = null;
let force = false;
let dryRun = false;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--dir" || arg === "-d") {
const next = argv[i + 1];
if (!next) {
throw new Error("--dir requires a path argument");
}
dir = next;
i += 1;
} else if (arg.startsWith("--dir=")) {
dir = arg.slice("--dir=".length);
} else if (arg === "--force") {
force = true;
} else if (arg === "--dry-run") {
dryRun = true;
} else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return {
dir: dir ?? resolve(process.cwd(), "docs"),
force,
dryRun,
};
}

function printHelp(): void {
console.log(`Usage: pnpm sync:docs [--dir <path>] [--force] [--dry-run]

Reads .md files from <path> (default: ./docs) and upserts each as a
content_item with type='note' and source='docs-sync'. Each doc is tagged
#project:shadowbrain, #docs, and a category tag derived from its path.
Files removed from disk are pruned. Re-runs are idempotent.

Options:
-d, --dir <path> Directory to sync (default: ./docs)
--force Re-write every file even if the stored content
matches the on-disk version.
--dry-run Preview changes without writing to the database.
-h, --help Show this help
`);
}

async function main(): Promise<void> {
let args: CliArgs;
try {
args = parseArgs(process.argv.slice(2));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Argument error: ${message}`);
printHelp();
process.exit(2);
}

const db = getDb();
try {
const result = await syncDocsDirectory(db, args.dir, {
skipUnchanged: !args.force,
dryRun: args.dryRun,
});
console.log(formatDocsSyncResult(result));
log("info", "docs sync complete", {
event: "docs.sync.complete",
directory: result.directory,
created: result.created,
updated: result.updated,
skipped: result.skipped,
deleted: result.deleted,
failed: result.failed,
force: args.force,
dryRun: args.dryRun,
});
if (result.failed > 0) {
process.exitCode = 1;
}
} finally {
closeDb();
}
}

main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Docs sync failed: ${message}`);
log("error", "docs sync crashed", {
event: "docs.sync.crash",
error: err instanceof Error ? { message, stack: err.stack } : message,
});
process.exit(1);
});
38 changes: 38 additions & 0 deletions src/app/browse/content-card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// @vitest-environment jsdom

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";

import {
ContentCard,
formatAbsoluteTime,
formatRelativeTime,
metadataSummary,
previewText,
Expand Down Expand Up @@ -97,6 +99,27 @@ describe("ContentCard", () => {
expect(time).toHaveAttribute("datetime");
});

it("does not carry a native title tooltip — the custom tooltip owns it", () => {
// The timestamp used to set `title={created_at}` (a slow,
// unstyled native tooltip with the raw ISO string). Now the
// Base UI tooltip shows the formatted absolute time instead.
render(<ContentCard item={baseItem} />);
expect(screen.getByRole("time")).not.toHaveAttribute("title");
});

it("reveals the absolute time in a tooltip on hover", async () => {
const user = userEvent.setup();
const item: BrowseItem = {
...baseItem,
created_at: "2026-06-21T12:00:00.000Z",
};
render(<ContentCard item={item} />);
await user.hover(screen.getByRole("time"));
// The popup is portalled to <body>; `screen` covers the whole
// document, so the absolute phrase is findable once open.
expect(await screen.findByText(/2026/)).toBeInTheDocument();
});

it("does not render the image frame when image_url is null", () => {
render(<ContentCard item={baseItem} />);
const card = screen.getByTestId("content-card");
Expand Down Expand Up @@ -243,6 +266,21 @@ describe("formatRelativeTime", () => {
});
});

describe("formatAbsoluteTime", () => {
it("formats an ISO timestamp as a medium date + short time", () => {
// Locale "en", UTC input. The exact hour depends on the
// runtime timezone, so we assert on the stable date + the
// four-digit year rather than the full string.
const out = formatAbsoluteTime("2026-06-21T12:00:00.000Z");
expect(out).toMatch(/Jun 21, 2026/);
expect(out).toMatch(/2026/);
expect(out).toMatch(/(:\d{2}|AM|PM)/);
});
it("returns the input unchanged when the timestamp is unparseable", () => {
expect(formatAbsoluteTime("not-a-date")).toBe("not-a-date");
});
});

describe("previewText", () => {
it("returns the input unchanged when shorter than the limit", () => {
expect(previewText("hello", 10)).toBe("hello");
Expand Down
55 changes: 45 additions & 10 deletions src/app/browse/content-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
import { useMemo, useState } from "react";

import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { BrowseItem } from "./types";

export interface ContentCardProps {
Expand Down Expand Up @@ -85,6 +90,14 @@ const TYPE_LABEL: Record<string, string> = {

const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });

/** Shared absolute formatter for the timestamp tooltip. `medium`
* date + `short` time reads as "Jun 22, 2026, 9:55 PM" — precise
* enough to disambiguate, compact enough for a one-line tip. */
const ABSOLUTE = new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "short",
});

/** Format a created_at timestamp as a short relative phrase
* ("just now", "12m ago", "3h ago", "2d ago", or the absolute
* date for anything older than a month). Falls back to the raw
Expand Down Expand Up @@ -117,6 +130,18 @@ export function formatRelativeTime(
return RELATIVE.format(Math.round(diffMs / year), "year");
}

/** Format a created_at timestamp as an absolute, human-readable
* date+time ("Jun 22, 2026, 9:55 PM"). Surfaced via the
* timestamp's hover/focus tooltip so the exact time is one hover
* away from the relative phrase. Falls back to the raw ISO string
* when the input is unparseable. */
export function formatAbsoluteTime(iso: string): string {
const then = new Date(iso);
const thenMs = then.getTime();
if (Number.isNaN(thenMs)) return iso;
return ABSOLUTE.format(then);
}

/** Truncate a string to `max` characters at a word boundary
* and append an ellipsis. Used for the content preview. */
export function previewText(content: string, max: number = 180): string {
Expand Down Expand Up @@ -170,6 +195,10 @@ export function ContentCard({
() => formatRelativeTime(item.created_at),
[item.created_at]
);
const absolute = useMemo(
() => formatAbsoluteTime(item.created_at),
[item.created_at]
);
const summary = metadataSummary(item.type, item.metadata);
// When the image 404s, show a text placeholder instead of the
// browser's default broken-image glyph. The file may genuinely
Expand All @@ -185,7 +214,7 @@ export function ContentCard({
data-has-image={item.image_url ? "true" : "false"}
data-variant={variant}
className={cn(
"border-border bg-surface-elevated relative flex flex-col overflow-hidden rounded-sm border",
"border-border bg-surface-elevated relative flex min-w-0 flex-col overflow-hidden rounded-sm border",
"hover:border-border-strong transition-colors"
)}
>
Expand Down Expand Up @@ -248,22 +277,28 @@ export function ContentCard({
{typeLabel}
</span>
)}
<time
dateTime={item.created_at}
title={item.created_at}
className="text-muted-foreground font-mono text-[0.7rem]"
>
{relative}
</time>
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={item.created_at}
className="text-muted-foreground hover:text-foreground cursor-help font-mono text-[0.7rem] transition-colors"
/>
}
>
{relative}
</TooltipTrigger>
<TooltipContent side="top">{absolute}</TooltipContent>
</Tooltip>
</header>

{item.title ? (
<h3 className="text-foreground font-serif text-lg leading-snug font-semibold tracking-[-0.01em]">
<h3 className="text-foreground font-serif text-lg leading-snug font-semibold tracking-[-0.01em] break-words">
{item.title}
</h3>
) : null}

<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed">
<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words">
{previewText(item.content)}
</p>

Expand Down
21 changes: 16 additions & 5 deletions src/app/browse/content-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@
* - `grid` (default) — items are split round-robin into N
* columns (N derived from the container width). Each column
* is an independent flex column so cards sit with their
* natural height and pack vertically. Ordering is left-to-
* right (item 0 → column 0, item 1 → column 1, …), which
* keeps the chronological timestamp order intact.
* natural height and pack vertically — cards keep varying
* heights and stack flush, like a Pinterest wall. Ordering is
* left-to-right (item 0 → column 0, item 1 → column 1, …),
* which keeps the chronological timestamp order intact.
*
* Each column carries `min-w-0`. Without it, a flex item's
* default `min-width: auto` resolves to its widest card's
* min-content (a long unbreakable token or a wide image), so
* one column would balloon to ~40% while the others shrank —
* the "messed-up columns" bug. `min-w-0` lets `flex-1` hold
* every column at an equal 1/N share; the card itself breaks
* long tokens with `break-words`, so nothing overflows.
* - `list` — single-column wide row.
*/

Expand Down Expand Up @@ -166,7 +175,9 @@ export function ContentFeed({
className="flex gap-3"
>
{skelCols.map((bucket, ci) => (
<div key={ci} className="flex flex-1 flex-col gap-3">
// `min-w-0` keeps the skeleton columns at an equal 1/N
// width, matching the success-state masonry below.
<div key={ci} className="flex min-w-0 flex-1 flex-col gap-3">
{bucket.map((i) => (
<div
key={i}
Expand Down Expand Up @@ -228,7 +239,7 @@ export function ContentFeed({
className="flex gap-3"
>
{masonryColumns.map((col, ci) => (
<div key={ci} className="flex flex-1 flex-col gap-3">
<div key={ci} className="flex min-w-0 flex-1 flex-col gap-3">
{col.map((item) => (
<ContentCard key={item.id} item={item} variant={cardVariant} />
))}
Expand Down
Loading
Loading