mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): a "What's new" page anchored at your last visit (BEA-65)
The change feed is flat reverse-chron with no notion of when you last looked, so "what happened while I was away" is date arithmetic in your head — and cheap agents write more files per day than that scales to. Server: `?since=<RFC3339>` on GET /api/p/<id>/history, one case in the existing filter switch. It sits after the kinds[] classification (which must keep walking every op, or a filtered view relabels an edit as an add) and before the sort, so next_cursor is minted from the filtered list and paging a since-feed terminates on its own oldest match. Client: /<project-id>/since — a real VIEW_ROUTES entry — renders the existing HistoryView with that filter and a header line. The anchor is a per-(account, project) localStorage marker, read once from a useState initializer and stamped after entries render: read it any later and the page empties itself while you are reading it. SinceView is the only caller of stampVisit, so no other page moves the marker. Known ceiling: the marker is per browser, not per account — laptop and phone keep separate last-visit times. The server-side marker is the larger follow-up.
This commit is contained in:
@@ -27,7 +27,7 @@ classDiagram
|
||||
}
|
||||
|
||||
class router {
|
||||
+VIEW_ROUTES dashboard history install settings
|
||||
+VIEW_ROUTES dashboard history since install settings
|
||||
+LEGACY_VIEWS insights to dashboard
|
||||
+top-level routes orgs billing
|
||||
+parseRoute(url, mode) Route
|
||||
@@ -69,6 +69,7 @@ classDiagram
|
||||
class components {
|
||||
FileView FolderListing FileTree
|
||||
HistoryView HistoryRow DiffView VersionBanner
|
||||
SinceView
|
||||
Insights ShareDialog NewProjectDialog
|
||||
ShareBanner SharesTable AdminTable
|
||||
OrgAdmin HubSettings ProjectSettings
|
||||
@@ -80,12 +81,13 @@ classDiagram
|
||||
class lib {
|
||||
+diff.ts splitLines lcsDiff diffText
|
||||
+runs.ts groupRuns runFileCount
|
||||
+lastVisit.ts lastVisit stampVisit (localStorage)
|
||||
+heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
|
||||
+heat.ts ageRange isFlatRange ageSpanLabel (treemap scale)
|
||||
+sniff.ts sniffBytes BlobText MAX_BYTES
|
||||
+utils.ts
|
||||
}
|
||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it"
|
||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. lastVisit.ts is the exception to the purity: the only localStorage in the frontend (the What's-new marker), every access try/caught so private mode degrades to the 7-day fallback rather than a white screen"
|
||||
|
||||
App --> HubApp
|
||||
App --> VolumeApp
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId } from "./helpers";
|
||||
|
||||
// "What's new" is the history feed anchored at a localStorage marker. Every
|
||||
// Playwright test gets a fresh context, so storage starts empty and "first
|
||||
// visit" needs no setup — which is also why each test that wants a stored
|
||||
// marker has to make one by visiting the page first.
|
||||
|
||||
test("first visit falls back to the last 7 days and says so", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/since`);
|
||||
await expect(page.locator(".since-sub")).toContainText("showing the last 7 days");
|
||||
// The seeded ops are 90min–72h old, so they are all inside the window.
|
||||
await expect(page.locator(".hrun-note")).toContainText("claude-code session 8f21e4");
|
||||
});
|
||||
|
||||
test("a change lands, then the revisit is empty", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/since`); // stamps the marker
|
||||
await expect(page.locator(".since-sub")).toContainText("since your last visit");
|
||||
|
||||
await page.request.put(
|
||||
`/api/p/${pid}/upload/content?path=${encodeURIComponent("since-probe.md")}`,
|
||||
{ data: "# Probe\n" },
|
||||
);
|
||||
await page.goto(`/${pid}/since`);
|
||||
await expect(page.locator(".since-sub")).toContainText("1 change since your last visit");
|
||||
await expect(page.locator(".hpath")).toHaveText(["since-probe.md"]);
|
||||
|
||||
await page.goto(`/${pid}/since`);
|
||||
await expect(page.locator(".since-sub")).toContainText("Nothing new since your last visit");
|
||||
await expect(page.locator(".hpath")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("only this view stamps the marker", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/since`); // marker: now
|
||||
await expect(page.locator(".since-sub")).toContainText("since your last visit");
|
||||
|
||||
// A change lands, then the user wanders through the other project pages.
|
||||
// If any of them stamped, the change below would be swallowed.
|
||||
await page.request.put(
|
||||
`/api/p/${pid}/upload/content?path=${encodeURIComponent("since-stamp-probe.md")}`,
|
||||
{ data: "# Stamp probe\n" },
|
||||
);
|
||||
await page.goto(`/${pid}/history`);
|
||||
await expect(page.locator(".history")).toBeVisible();
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
|
||||
await page.goto(`/${pid}/since`);
|
||||
await expect(page.locator(".hpath")).toHaveText(["since-stamp-probe.md"]);
|
||||
});
|
||||
|
||||
test("the baseline stays put while the page is open", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/since`);
|
||||
await expect(page.locator(".since-sub")).toContainText("showing the last 7 days");
|
||||
const shown = (await page.locator(".since-sub").textContent()) || "";
|
||||
// The marker was written under us; the page must not notice.
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(page.locator(".since-sub")).toHaveText(shown);
|
||||
});
|
||||
|
||||
test("the nav item navigates there and is the only active row", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}`);
|
||||
await page.click("#nav-since");
|
||||
await expect(page).toHaveURL(new RegExp(`/${pid}/since$`));
|
||||
await expect(page.locator(".nav-menu .row.active")).toHaveCount(1);
|
||||
await expect(page.locator("#nav-since")).toHaveClass(/active/);
|
||||
// A deep link survives a hard reload (SPA fallback), not just client nav.
|
||||
await page.reload();
|
||||
await expect(page.locator(".since-head")).toHaveText("What's new");
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { Palette, type PaletteItem } from "../components/Palette";
|
||||
import { ConnectGuide } from "../components/ConnectGuide";
|
||||
import { Insights, useInsightsDevices } from "../components/Insights";
|
||||
import { HistoryView, historyTitle } from "../components/HistoryView";
|
||||
import { SinceView } from "../components/SinceView";
|
||||
import { VersionBanner } from "../components/VersionBanner";
|
||||
|
||||
// The browsing surface shared by hub projects and single-volume mode: the
|
||||
@@ -337,6 +338,22 @@ export default function Browser(props: {
|
||||
isFolder={isFolderFn}
|
||||
/>
|
||||
);
|
||||
} else if (route.view === "since" && project) {
|
||||
// Whole project only — no viewTarget in v1, so a subtree link falls
|
||||
// through to History.
|
||||
view = (
|
||||
<SinceView
|
||||
apiBase={apiBase}
|
||||
projectId={project.id}
|
||||
account={config.me?.email}
|
||||
isFolder={isFolderFn}
|
||||
onOpen={openPath}
|
||||
onMeta={setMeta}
|
||||
onRendered={onRendered}
|
||||
restore={canRestore ? { onRestore, busy: restoring } : undefined}
|
||||
remove={canRestore ? { onRemove, busy: removing } : undefined}
|
||||
/>
|
||||
);
|
||||
} else if (route.view === "history") {
|
||||
// structured view — default app column, like the folder listing it shares rows with
|
||||
view = (
|
||||
@@ -447,6 +464,8 @@ export default function Browser(props: {
|
||||
<Breadcrumbs path={path} onOpenFolder={openPath} />
|
||||
) : route.view === "dashboard" ? (
|
||||
"Dashboard — " + (route.viewTarget || project?.name || "")
|
||||
) : route.view === "since" ? (
|
||||
"What's new — " + (project?.name || "")
|
||||
) : route.view === "history" ? (
|
||||
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
|
||||
) : isHome ? (
|
||||
|
||||
@@ -297,11 +297,13 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
? "dashboard"
|
||||
: route.view === "install"
|
||||
? "install"
|
||||
: route.view === "history" && !route.viewTarget
|
||||
? "history"
|
||||
: route.view === "settings"
|
||||
? "settings"
|
||||
: null,
|
||||
: route.view === "since"
|
||||
? "since"
|
||||
: route.view === "history" && !route.viewTarget
|
||||
? "history"
|
||||
: route.view === "settings"
|
||||
? "settings"
|
||||
: null,
|
||||
// Each page is a URL; explicitly close overlay panels because
|
||||
// same-path navigation doesn't change pathname.
|
||||
onDashboard: () => {
|
||||
@@ -314,6 +316,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
navigate(urlForView("install", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
onSince: () => {
|
||||
setPanel(null);
|
||||
navigate(urlForView("since", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
onHistory: () => {
|
||||
setPanel(null);
|
||||
navigate(urlForView("history", current.id));
|
||||
|
||||
@@ -28,17 +28,25 @@ export function HistoryView(props: {
|
||||
onRendered?: () => void;
|
||||
restore?: RestoreAction;
|
||||
remove?: RemoveAction;
|
||||
/* The three below are what "What's new" (SinceView) needs and /history
|
||||
does not: with all three absent this renders exactly as it always did. */
|
||||
since?: string; // RFC3339; narrows the feed to changes after it
|
||||
emptyText?: string; // "" when a wrapper carries the empty message itself
|
||||
onLoaded?: (n: number, more: boolean) => void; // entries loaded so far, and whether more exist
|
||||
}) {
|
||||
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove } = props;
|
||||
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, since, onLoaded } = props;
|
||||
const q = !target
|
||||
? { prefix: "" }
|
||||
: isFolder(target)
|
||||
? { prefix: target + "/" }
|
||||
: { path: target };
|
||||
const qs =
|
||||
"path" in q && q.path !== undefined
|
||||
("path" in q && q.path !== undefined
|
||||
? "path=" + encodeURIComponent(q.path)
|
||||
: "prefix=" + encodeURIComponent(q.prefix ?? "");
|
||||
: "prefix=" + encodeURIComponent(q.prefix ?? "")) +
|
||||
// Part of qs, so it is also part of the queryKey: a revisit with a fresh
|
||||
// baseline refetches instead of showing the previous visit's cached page.
|
||||
(since ? "&since=" + encodeURIComponent(since) : "");
|
||||
// Paged: the server hands back a cursor while entries remain, so a project
|
||||
// with thousands of changes is reachable to its first one. Pages accumulate
|
||||
// into one array — groupRuns and prevBlob both work over the whole window,
|
||||
@@ -65,6 +73,9 @@ export function HistoryView(props: {
|
||||
useEffect(() => {
|
||||
if (data) onRendered?.();
|
||||
}, [data, onRendered]);
|
||||
useEffect(() => {
|
||||
if (data) onLoaded?.(data.pages.reduce((n, p) => n + (p.entries || []).length, 0), hasNextPage);
|
||||
}, [data, hasNextPage, onLoaded]);
|
||||
|
||||
if (!data) return null;
|
||||
const entries = data.pages.flatMap((p) => p.entries || []);
|
||||
@@ -87,7 +98,9 @@ export function HistoryView(props: {
|
||||
entries[i].kind === "delete" ? prevBlob(i) : entries[i].blob;
|
||||
return (
|
||||
<div className="history">
|
||||
{entries.length === 0 && <div className="empty">No history yet.</div>}
|
||||
{entries.length === 0 && (props.emptyText ?? "No history yet.") !== "" && (
|
||||
<div className="empty">{props.emptyText ?? "No history yet."}</div>
|
||||
)}
|
||||
{groupRuns(entries).map((item, n) =>
|
||||
item.run ? (
|
||||
<RunGroup
|
||||
|
||||
@@ -20,9 +20,10 @@ export function projColor(s: string): string {
|
||||
}
|
||||
|
||||
export interface ProjectMenu {
|
||||
active: "dashboard" | "install" | "history" | "settings" | null;
|
||||
active: "dashboard" | "install" | "since" | "history" | "settings" | null;
|
||||
onDashboard: () => void;
|
||||
onInstall: () => void;
|
||||
onSince: () => void;
|
||||
onHistory: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
@@ -113,6 +114,8 @@ export function ProjectNav({
|
||||
[
|
||||
["dashboard", "Dashboard", "dashboard", menu.onDashboard],
|
||||
["install", "Installation", "terminal", menu.onInstall],
|
||||
/* the two change-feed views sit together, catch-up first */
|
||||
["since", "What's new", "clock", menu.onSince],
|
||||
["history", "History", "hist", menu.onHistory],
|
||||
["settings", "Settings", "gear", menu.onSettings],
|
||||
] as const
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { HistoryView } from "./HistoryView";
|
||||
import type { RemoveAction, RestoreAction } from "./HistoryRow";
|
||||
import { lastVisit, stampVisit } from "../lib/lastVisit";
|
||||
|
||||
/* ---- what's new ----
|
||||
The change feed, cut to what landed since you were last here. Everything
|
||||
below the header is HistoryView unchanged — same run cards, same rows,
|
||||
same diffs — so this file is only the anchor: read the marker once, show
|
||||
what it means, then move it.
|
||||
|
||||
Two things are the whole reason this wrapper exists:
|
||||
|
||||
* the baseline comes from a useState INITIALIZER, so it is frozen for the
|
||||
life of the mount. Read it during render or in an effect and the page
|
||||
empties itself while you are reading it — it would re-read the marker it
|
||||
just wrote;
|
||||
* stamping is behind a ref guard, so "Load more" cannot restamp. Harmless
|
||||
either way (the baseline is frozen) but the invariant should be visible.
|
||||
|
||||
This is the only call site of stampVisit: no other project page moves the
|
||||
marker, which is what makes "what's new" mean anything. */
|
||||
export function SinceView(props: {
|
||||
apiBase: string;
|
||||
projectId: string;
|
||||
account?: string;
|
||||
isFolder: (p: string) => boolean;
|
||||
onOpen: (path: string, version?: string) => void;
|
||||
onMeta: (meta: string) => void;
|
||||
onRendered?: () => void;
|
||||
restore?: RestoreAction;
|
||||
remove?: RemoveAction;
|
||||
}) {
|
||||
const { projectId, account } = props;
|
||||
const [base] = useState(() => lastVisit(projectId, account));
|
||||
const [loaded, setLoaded] = useState<{ n: number; more: boolean } | null>(null);
|
||||
const stamped = useRef(false);
|
||||
|
||||
const onLoaded = useCallback(
|
||||
(n: number, more: boolean) => {
|
||||
setLoaded({ n, more });
|
||||
if (stamped.current) return;
|
||||
stamped.current = true;
|
||||
stampVisit(projectId, account);
|
||||
},
|
||||
[projectId, account],
|
||||
);
|
||||
|
||||
const when = new Date(base.since).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
let sub: string;
|
||||
if (!loaded) sub = "Looking for changes…";
|
||||
else if (loaded.n === 0) sub = "Nothing new since your last visit · " + when;
|
||||
else {
|
||||
const n = loaded.n + (loaded.more ? "+" : "");
|
||||
sub = `${n} change${loaded.n === 1 && !loaded.more ? "" : "s"} since your last visit · ${when}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="since">
|
||||
<h1 className="since-head">What's new</h1>
|
||||
<div className="since-sub">
|
||||
{sub}
|
||||
{base.first && (
|
||||
<span className="since-first"> — showing the last 7 days; we'll remember this visit.</span>
|
||||
)}
|
||||
</div>
|
||||
<HistoryView
|
||||
apiBase={props.apiBase}
|
||||
target=""
|
||||
isFolder={props.isFolder}
|
||||
onOpen={props.onOpen}
|
||||
onMeta={props.onMeta}
|
||||
onRendered={props.onRendered}
|
||||
restore={props.restore}
|
||||
remove={props.remove}
|
||||
since={base.since}
|
||||
emptyText="" /* the subline above already says it */
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// The last-visit marker behind "What's new" (/<project-id>/since) — the one
|
||||
// storage-touching module in an otherwise pure lib/, and the only
|
||||
// localStorage in the frontend. Every access is wrapped: Safari private mode
|
||||
// and disabled site storage throw on get and on set alike, and a catch-up
|
||||
// view is not worth a white screen.
|
||||
//
|
||||
// Keyed by account as well as project: two people sharing a laptop (or a demo
|
||||
// switching personas) would otherwise steal each other's last-visit time.
|
||||
// Per browser, not per account on the server — a laptop and a phone keep
|
||||
// separate markers. That is the price of the small version.
|
||||
|
||||
const key = (project: string, account?: string) =>
|
||||
"bdrive.lastVisit." + (account || "anon") + "." + project;
|
||||
|
||||
const WINDOW_DAYS = 7;
|
||||
|
||||
// The baseline for one mount, read exactly once. No marker (first visit,
|
||||
// fresh browser, storage off) falls back to the last 7 days, flagged so the
|
||||
// view can say so rather than pretending it knows when you were last here.
|
||||
export function lastVisit(project: string, account?: string): { since: string; first: boolean } {
|
||||
let v: string | null = null;
|
||||
try {
|
||||
v = localStorage.getItem(key(project, account));
|
||||
} catch {
|
||||
/* storage off — treat as a first visit */
|
||||
}
|
||||
return v
|
||||
? { since: v, first: false }
|
||||
: { since: new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString(), first: true };
|
||||
}
|
||||
|
||||
export function stampVisit(project: string, account?: string): void {
|
||||
try {
|
||||
localStorage.setItem(key(project, account), new Date().toISOString());
|
||||
} catch {
|
||||
/* storage off — the view degrades to "always the last 7 days" */
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,19 @@ export function decodePath(p: string): string {
|
||||
// after the project id is reserved when it names a view:
|
||||
// /<project-id>/dashboard[/<path>] the read×staleness dashboard (optionally scoped)
|
||||
// /<project-id>/history[/<path>] change feed (project / subtree / file)
|
||||
// /<project-id>/since the change feed since your last visit
|
||||
// /<project-id>/install connect-a-device guide
|
||||
// /<project-id>/settings project settings
|
||||
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
|
||||
// view routes here, not ephemeral panel state. (Root-level files literally
|
||||
// named like a view lose the URL shortcut and remain reachable via the tree.)
|
||||
export const VIEW_ROUTES = new Set(["dashboard", "history", "install", "settings"]);
|
||||
export const VIEW_ROUTES = new Set(["dashboard", "history", "since", "install", "settings"]);
|
||||
|
||||
// Shipped URLs that were renamed. Parsed into the new view and normalized
|
||||
// away on arrival, so bookmarks resolve without a second live name.
|
||||
const LEGACY_VIEWS: Record<string, ViewName> = { insights: "dashboard" };
|
||||
|
||||
export type ViewName = "dashboard" | "history" | "install" | "settings";
|
||||
export type ViewName = "dashboard" | "history" | "since" | "install" | "settings";
|
||||
|
||||
export interface Route {
|
||||
// Org administration is not project-scoped, so it is a top-level route
|
||||
|
||||
@@ -658,6 +658,11 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.in-matrix rect { transition: opacity .1s; }
|
||||
.in-matrix rect:hover { opacity: .85; }
|
||||
|
||||
/* ---- what's new (the history feed, anchored at your last visit) ---- */
|
||||
.since-head { margin: 0; font-size: 19px; font-weight: 600; color: var(--text); }
|
||||
.since-sub { margin: 5px 0 16px; font-size: 13px; color: var(--text-dim); }
|
||||
.since-first { color: var(--text-faint); }
|
||||
|
||||
/* ---- history ---- */
|
||||
/* .history width comes from .page (app) */
|
||||
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); --hindent: 72px; }
|
||||
|
||||
@@ -98,7 +98,9 @@ func decodeCursor(s string) (journal.Op, error) {
|
||||
|
||||
// handleHistory serves ?path=<file> (one file's versions) or
|
||||
// ?prefix=<folder/> (everything underneath, "" = the whole project),
|
||||
// newest first by wall-clock time, at most ?n= entries (default 100).
|
||||
// optionally narrowed to changes strictly newer than ?since=<RFC3339>
|
||||
// (what the "What's new" view asks for), newest first by wall-clock time,
|
||||
// at most ?n= entries (default 100).
|
||||
//
|
||||
// Paging: the response carries next_cursor when more entries exist, and
|
||||
// ?cursor= resumes just past the entry it was minted from — so history older
|
||||
@@ -129,6 +131,14 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
}
|
||||
var since time.Time
|
||||
if raw := q.Get("since"); raw != "" {
|
||||
var err error
|
||||
if since, err = time.Parse(time.RFC3339, raw); err != nil {
|
||||
http.Error(w, "invalid since", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
all, err := rs.loadOps(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
@@ -164,6 +174,13 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
continue
|
||||
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
|
||||
continue
|
||||
// Strictly after, so re-asking with the timestamp of the newest entry
|
||||
// you've already seen doesn't hand it back a second time. Filtering
|
||||
// here — after kinds[], before the sort — means next_cursor is minted
|
||||
// from the filtered list, so paging a since-feed terminates on its own
|
||||
// oldest match.
|
||||
case !since.IsZero() && !op.Time.After(since):
|
||||
continue
|
||||
}
|
||||
// Unregistered device (or volume mode, where Devices is nil): fall back
|
||||
// to the op's own id + self-reported name.
|
||||
|
||||
@@ -400,6 +400,88 @@ func TestHistoryPagingAcrossLamportAndTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ?since= is the anchor the "What's new" view is built on: the same feed,
|
||||
// cut to what landed after a timestamp. It composes with the path/prefix
|
||||
// filter and with cursor paging, because it is applied in the same switch
|
||||
// and before the same sort.
|
||||
func TestHistorySince(t *testing.T) {
|
||||
srv, p, root := newHub(t, false, nil)
|
||||
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
||||
t1 := time.Date(2026, 7, 26, 9, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
t3 := time.Date(2026, 7, 26, 15, 0, 0, 0, time.UTC)
|
||||
f.putAt("dev", "notes/old.md", "old", t1)
|
||||
f.putAt("dev", "notes/mid.md", "mid", t2)
|
||||
f.putAt("dev", "docs/new.md", "new", t3)
|
||||
|
||||
h := srv.Handler()
|
||||
base := "/api/p/" + p.ID + "/"
|
||||
page := func(u string) ([]string, string) {
|
||||
t.Helper()
|
||||
rec := do(t, h, "GET", u, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("history: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var out struct {
|
||||
Entries []HistoryEntry `json:"entries"`
|
||||
Next string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, e := range out.Entries {
|
||||
got = append(got, e.Path)
|
||||
}
|
||||
return got, out.Next
|
||||
}
|
||||
arg := func(at time.Time) string { return "since=" + url.QueryEscape(at.Format(time.RFC3339)) }
|
||||
|
||||
// only what landed after the marker, newest first
|
||||
if got, _ := page(base + "history?" + arg(t1.Add(time.Minute))); !slices.Equal(got, []string{"docs/new.md", "notes/mid.md"}) {
|
||||
t.Fatalf("since=t1+1m = %v", got)
|
||||
}
|
||||
// strictly after: an op at exactly the marker is already seen
|
||||
if got, _ := page(base + "history?" + arg(t2)); !slices.Equal(got, []string{"docs/new.md"}) {
|
||||
t.Fatalf("since=t2 = %v, want the t2 op excluded", got)
|
||||
}
|
||||
// composes with prefix=: both filters apply
|
||||
if got, _ := page(base + "history?prefix=notes/&" + arg(t1)); !slices.Equal(got, []string{"notes/mid.md"}) {
|
||||
t.Fatalf("since+prefix = %v", got)
|
||||
}
|
||||
// composes with path=
|
||||
if got, _ := page(base + "history?path=notes/old.md&" + arg(t1)); len(got) != 0 {
|
||||
t.Fatalf("since+path = %v, want empty", got)
|
||||
}
|
||||
// a marker older than everything is the unfiltered feed, byte for byte
|
||||
full := do(t, h, "GET", base+"history", nil).Body.String()
|
||||
old := do(t, h, "GET", base+"history?"+arg(t1.Add(-24*time.Hour)), nil).Body.String()
|
||||
if full != old {
|
||||
t.Fatalf("since=<very old> differs from no since:\n%s\n%s", old, full)
|
||||
}
|
||||
// paging a since-feed reaches its oldest match and then stops
|
||||
var got []string
|
||||
cursor, pages := "", 0
|
||||
for {
|
||||
entries, next := page(base + "history?n=1&" + arg(t1) + cursorArg(cursor))
|
||||
got = append(got, entries...)
|
||||
if pages++; pages > 5 {
|
||||
t.Fatalf("paging did not terminate: %v", got)
|
||||
}
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
if !slices.Equal(got, []string{"docs/new.md", "notes/mid.md"}) {
|
||||
t.Fatalf("paged since-feed = %v", got)
|
||||
}
|
||||
// unparseable is an error, not a silently unfiltered feed
|
||||
if rec := do(t, h, "GET", base+"history?since=yesterday", nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad since: %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHistoryPage measures what a deep page costs: every request
|
||||
// re-lists and re-parses every journal (loadOps), so page 20 should cost
|
||||
// about what page 1 costs — the ceiling is gone, the per-page work is not.
|
||||
|
||||
+43
-43
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DKWyO9UQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--FdGCYbV.css">
|
||||
<script type="module" crossorigin src="/assets/index-BJXwJ42N.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJC1dk7l.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user