8000 feat(timeline): a Timeline page to review what came in, and Latest on Home by adeelahmad · Pull Request #437 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
3 changes: 2 additions & 1 deletion docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ merged is called built.
| The action registry with risk from each action's effect, and one action, `log_line` (`app/domain/actions.py`) | built |
| Telling a person: the `notify` action and `channels.tell` (their own chat, a notification target, a Matterbridge room) | built |
| Settings → Automations: how often followers look and how long the event log is kept | built |
| Storing and running automations, schedules and timers, joins, the other actions, approvals, variables, the timeline page, the studio | planned |
| The Timeline page with filters, grouping, review keys and new items, and Home's Latest card ([Timeline](timeline.md)) | built |
| Storing and running automations, schedules and timers, joins, the other actions, approvals, variables, the studio | planned |

## Contract

Expand Down
6 changes: 3 additions & 3 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ nextjs-frontend/

The nav rail (`components/app-shell/nav-config.ts`) is in four groups, each named above its first item (a line when
the rail is collapsed to icons): what people use every day (Home, Library, Search, Chat, Notes), **Explore** what Lens
found (Entities, Topics, Graph, Podcasts, Reports), **Automate** what runs by itself (Pipelines, Routines, Extensions,
Sensors, or Sources for people who aren't admins), and **Manage** Lens (Costs, Analytics, Settings, Admin). Every page
stays one click from Home; ⌘K reaches every page, recording and setting by name.
found (Timeline, Entities, Topics, Graph, Podcasts, Reports), **Automate** what runs by itself (Pipelines, Routines,
Extensions, Sensors, or Sources for people who aren't admins), and **Manage** Lens (Costs, Analytics, Settings,
Admin). Every page stays one click from Home; ⌘K reaches every page, recording and setting by name.

## Design system

Expand Down
40 changes: 38 additions & 2 deletions docs/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Status: in progress. The studio's timeline is built in steps; only what is merge
| --- | --- |
| The timeline API (`GET /api/v1/activities`), the MCP tool `timeline` and the assistant's `timeline` | built |
| Review marks: reviewed, important and hidden, per person; the filters on them; `mark_activities` for agents | built |
| The Timeline page under Explore: filters, grouping, review with keys, new items as they come | planned |
| A Latest card on Home with the newest unreviewed items | planned |
| The Timeline page under Explore: filters, grouping, review with keys, new items as they come | built |
| A Latest card on Home with the newest unreviewed items | built |
| What each item caused (automation runs, approvals), and Approve and Decline on waiting approvals | planned |

## What shows
Expand All @@ -21,6 +21,42 @@ more kinds as the [automations](automations.md#kinds) modules add them). Activit
web-app chat) show to them alone, and those for admins to admins. Noisy kinds, like sensor readings, are left out
unless you ask for them by kind.

## The page

**Timeline** under Explore in the navigation, for everyone (members see their namespaces).

- Newest first. **Older** reads the next page. While the tab is visible the page looks for new items every 15
seconds; when there are some, a **3 new** pill at the top brings them in, so the list never jumps under you.
- A run of the same source or person and the same kind, each within 30 minutes of the one before, is one line
("Work Gmail received 37 emails"), so a first mailbox sync reads as one line. A click opens it to its items.
- Each line has an icon by kind, the summary (a link to what it is about), who did it, the namespace, the kind and
how long ago. Opened, an item shows its object's fields, the automation run that caused it if one did, **Open** and
**Hide**.
- Filters: words in the summary (Enter applies), **Unreviewed only**, **Important**, kinds by family (Email,
Calendar, Files, Notes, Sensors, Chat, Automations, System; noisy kinds like sensor readings only when picked),
source or person (from those on the timeline), namespace, entity (as activities tag it), a date range, and an
automation (what it did or caused). Filters live in the address, so a view can be linked, and the last set you used
comes back the next time you open the page in this browser. **Clear** drops them all.
- On a phone the list is one column and the filters open in a sheet from **Filters**.

### Reviewing

| Key | Does |
| --- | --- |
| `j` / `k` | move to the next or previous line |
| `Enter` | open or close the line |
| `r` | mark it reviewed, or not |
| `i` | mark it important, or not |

Hovering or focusing a line shows its **Important** (star) and **Reviewed** (circle) toggles; once set they stay in
sight. On a line of several items they mark every item. **Mark all shown as reviewed** marks everything on the page
you haven't reviewed, with **Undo** in the message rather than a confirm; **Hide** works the same way.

### Home

The overview on Home has a **Latest** card with the five newest activities you haven't reviewed and a link to the
timeline with **Unreviewed only** on.

## Review marks

Each person marks activities for themselves alone; nobody else sees or sets them, admins included.
Expand Down
12 changes: 11 additions & 1 deletion nextjs-frontend/__tests__/nav-groups.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

import { Nav } from "@/components/app-shell/nav";
import { isActive, navFor } from "@/components/app-shell/nav-config";
import { TooltipProvider } from "@/components/ui/tooltip";

jest.mock("next/navigation", () => ({ usePathname: () => "/settings/llm" }));
Expand All @@ -17,7 +18,8 @@ const nav = (collapsed: boolean) =>
test("names each group above its first item", () => {
nav(false);
const text = screen.getByRole("navigation", { name: "Primary" }).textContent ?? "";
expect(text.indexOf("Explore")).toBeLessThan(text.indexOf("Entities"));
expect(text.indexOf("Explore")).toBeLessThan(text.indexOf("Timeline"));
expect(text.indexOf("Timeline")).toBeLessThan(text.indexOf("Entities"));
expect(text.indexOf("Notes")).toBeLessThan(text.indexOf("Explore"));
expect(text.indexOf("Automate")).toBeLessThan(text.indexOf("Pipelines"));
expect(text.indexOf("Manage")).toBeLessThan(text.indexOf("Costs"));
Expand All @@ -29,3 +31,11 @@ test("collapsed to icons, the groups are lines rather than names", () => {
expect(screen.queryByText("Explore")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Entities" })).toBeInTheDocument();
});

test("the timeline is under Explore for everyone", () => {
for (const admin of [true, false]) {
const t = navFor(admin).find((i) => i.href === "/timeline");
expect(t?.group).toBe("Explore");
}
expect(isActive("/timeline", "/timeline")).toBe(true);
});
162 changes: 162 additions & 0 deletions nextjs-frontend/__tests__/timeline-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
automationWhere,
familyOf,
filterCount,
fromParams,
group,
groupSummary,
isFiltered,
kindsByFamily,
mergeNew,
newest,
NO_FILTERS,
objectFields,
objectHref,
toParams,
toQuery,
unseen,
type Activity,
} from "@/components/timeline/model";

const MIN = 60_000;
const T0 = Date.parse("2026-10-15T12:00:00.000Z");

function act(
id: string,
minutesAgo: number,
kind = "asset.added",
actor = "lens:source/1",
name = "Work Gmail",
): Activity {
return {
id,
type: "Create",
kind,
published: new Date(T0 - minutesAgo * MIN).toISOString(),
actor: { type: "Service", id: actor, name },
object: { type: "Note", id: `lens:recording/${id}`, name: id, url: `/resources/${id}` },
summary: `${name} added “${id}”`,
space: 3,
tag: [],
lens: { source: "recordings", pos: [] },
};
}

describe("filters in the address", () => {
it("go to and from the query, so a view can be linked", () => {
const f = {
...NO_FILTERS,
kinds: ["email.received", "file.added"],
actor: ["lens:source/12"],
namespace: ["family"],
tag: "St Mary's School",
from: "2026-10-01",
to: "2026-10-15",
automation: "4",
q: "school trip",
unreviewed: true,
important: true,
};
const p = toParams(f);
expect(p.toString()).toBe(
"kinds=email.received%2Cfile.added&actor=lens%3Asource%2F12&ns=family&tag=St+Mary%27s+School&from=2026-10-01&to=2026-10-15&automation=4&q=school+trip&unreviewed=1&important=1",
);
expect(fromParams(p)).toEqual(f);
expect(filterCount(f)).toBe(10);
});

it("leave out what isn't set, and drop what is malformed", () => {
expect(toParams(NO_FILTERS).toString()).toBe("");
expect(isFiltered(NO_FILTERS)).toBe(false);
const f = fromParams(new URLSearchParams("from=yesterday&to=2026-13&automation=x&unreviewed=yes&kinds=,a.b,")); 8
expect(f).toEqual({ ...NO_FILTERS, kinds: ["a.b"] });
});

it("make the API's query, always leaving out what you hid", () => {
expect(toQuery(NO_FILTERS)).toEqual({ hidden: false });
const q = toQuery({
...NO_FILTERS,
unreviewed: true,
important: true,
automation: "4",
from: "2026-10-01",
to: "2026-10-02",
});
expect(q.reviewed).toBe(false);
expect(q.important).toBe(true);
expect(q.where).toBe(automationWhere("4"));
expect(JSON.parse(q.where as string).any).toHaveLength(2);
// the whole of both days, in the viewer's own time
expect(Date.parse(q.since as string)).toBe(new Date(2026, 9, 1).getTime() - 1);
expect(Date.parse(q.until as string)).toBe(new Date(2026, 9, 2, 23, 59, 59, 999).getTime());
});
});

describe("grouping", () => {
it("collapses a run of the same actor and kind within 30 minutes into one line", () => {
const items = [act("a", 0), act("b", 5), act("c", 30), act("d", 61), act("e", 62, "email.received"), act("f", 63)];
const gs = group(items);
expect(gs.map((g) => g.items.map((a) => a.id))).toEqual([["a", "b", "c"], ["d"], ["e"], ["f"]]);
expect(groupSummary(gs[0])).toBe("Work Gmail added 3 assets");
expect(groupSummary(gs[1])).toBe("Work Gmail added “d”");
});

it("reads a first mailbox sync as one line, and keeps other actors apart", () => {
const sync = Array.from({ length: 37 }, (_, i) => act(`m${i}`, i, "email.received"));
const other = act("x", 40, "email.received", "lens:source/2", "Home Gmail");
const gs = group([...sync, other]);
expect(gs).toHaveLength(2);
expect(groupSummary(gs[0])).toBe("Work Gmail received 37 emails");
expect(
groupSummary({ key: "k", items: [act("1", 0, "thing.done"), act("2", 1, "thing.done")] }, "Thing done"),
).toBe("Work Gmail: 2 × thing done");
});
});

describe("new items", () => {
it("are those not shown yet, merged on top newest first without doubles", () => {
const shown = [act("b", 10), act("a", 20)];
const incoming = [act("c", 5), act("b", 10), act("d", 1)];
expect(unseen(shown, incoming).map((a) => a.id)).toEqual(["c", "d"]);
expect(mergeNew(shown, incoming).map((a) => a.id)).toEqual(["d", "c", "b", "a"]);
expect(newest(mergeNew(shown, incoming))).toBe(act("d", 1).published);
expect(newest([])).toBeUndefined();
});
});

describe("kinds and objects", () => {
it("puts each kind under its family", () => {
expect(familyOf("email.received")).toBe("Email");
expect(familyOf("calendar_event.added")).toBe("Calendar");
expect(familyOf("asset.added")).toBe("Files");
expect(familyOf("sensor.reading")).toBe("Sensors");
expect(familyOf("chat.message")).toBe("Chat");
expect(familyOf("automation.ran")).toBe("Automations");
expect(familyOf("note.saved")).toBe("Notes");
expect(familyOf("backup.finished")).toBe("System");
const fam = kindsByFamily([
{ name: "file.added", label: "File added" },
{ name: "email.received", label: "Email received" },
{ name: "asset.added", label: "Asset added" },
]);
expect(fam.map(([f, ks]) => [f, ks.map((k) => k.name)])).toEqual([
["Email", ["email.received"]],
["Files", ["asset.added", "file.added"]],
]);
});

it("links an activity to its object and lists the object's own fields", () => {
const a = act("7", 0);
expect(objectHref(a)).toBe("/resources/7");
expect(objectHref({ ...a, object: { type: "Note", id: "lens:entity/9" } })).toBe("/entities/9");
expect(objectHref({ ...a, object: { type: "Note", id: "lens:nothing/9" } })).toBeNull();
const fields = objectFields({
...a,
object: { ...a.object, media: "document", from: ["office@stmarys.org"], deep: { x: 1 }, empty: "" },
});
expect(fields).toEqual([
["media", "document"],
["from", "office@stmarys.org"],
]);
});
});
Loading
Loading
0