NyhetN — project news
NyhetN is the example's project news app: a calm, read-only feed of information posts, weekly Friday digests, milestones, and alerts for one building. It is where the proactive agent goes to answer "what's the latest on the prefab delivery?" — it reads these posts and replies from reality.
It is project-scoped: every NyhetN page lives under /project/$projectKey, so you are
always looking at one building's feed. (The global surfaces — the home,
Carola, universal search, and the admin area —
aren't project-scoped.)
Coming from Django? Think of NyhetN as a small Django "news / announcements" app — a
Postmodel with categories — except there are no edit views: the UI is read-only, posts are created by the seed, and the feed is queried by the AI agent through Postgres full-text search rather than served to a template. See Coming from Django.
Where it lives
The logic lives in the @tikab-interactive/pulsn-apps package
(/nyhetn subpath); the example is a thin consumer.
| Layer | Where it lives |
|---|---|
| Logic (package) | @tikab-interactive/pulsn-apps/nyhetn — schema, the feed read (getNyhetnFeed) + Swedish-FTS searchNews |
| Transport | thin createServerFn wrapper in src/lib/nyhetn-server.ts; the agent's search via src/lib/knowledge-search.ts |
| UI | fusion-ui's NewsFeed element |
| Routes (pages) | src/routes/_authed.project.$projectKey.nyhetn.tsx |
Data model
Two tables, no per-row permissions — the building's feed is shared by everyone on it.
nyhetn_category— the chip you filter by:name, a uniqueslug, acolor, and asortOrderfor the chip row.nyhetn_post— one entry. Carries atitle, a Markdownbody, acategoryId(FK,onDelete: "cascade"), a free-textbuildinglabel, anauthorName, andpublishedAt.- Severity & pinning —
severityis anyhetn_severityenum (info/warning/critical) that drives the coloured accent;pinnedfloats a post to the top. projectKey— the route key of the building this post belongs to (e.g."EKEN-A"). Nullable, so a post can be program-wide / unscoped — it then surfaces in no building's feed. All rows share one database alongside ProcessN and WikiN.
Pages
| URL | What it shows |
|---|---|
/project/$projectKey/nyhetn | the building's news feed — pinned first then newest, with category filter chips; alerts (warning / critical) carry a coloured left accent |
There is no admin CRUD for NyhetN — the UI is read-only and exposes no create/edit
route. Posts get into the feed via bun run db:seed (see the
seed), which gives every building real news including at least
one alert.
How it loads
The feed is a textbook loader → server function.
getNyhetnFeed returns the whole feed in one read — categories (for the filter chips) and
posts, pinned first then newest — and the component filters client-side:
export const Route = createFileRoute("/_authed/project/$projectKey/nyhetn")({
loader: ({ params }) => getNyhetnFeed({ data: { projectKey: params.projectKey } }),
component: NewsFeedPage,
});The NewsFeed component reads { categories, posts } from Route.useLoaderData() and keeps
an active category in useState; the pinned-first ordering is already done in SQL, so the
filter chips just narrow which posts are shown.
getNyhetnFeed is a createServerFn({ method: "GET" }) with a Zod-validated
{ projectKey }, so the compiler strips the handler and db from the client bundle. It
innerJoins posts to their category and filters where(eq(nyhetnPost.projectKey, …)) —
one building's posts only.
Asking the news (RAG / search)
This is NyhetN's distinctive bit. searchNews is exposed to Carola as the
search_project_news tool, so she answers "what's the latest on X?" straight from the feed
by running Postgres full-text search over the posts — no embedder, so it works fully
offline / air-gapped. The tool calls searchNews() in
knowledge-search.ts, which builds a Swedish to_tsvector over title || body, matches a
websearch_to_tsquery, ranks by ts_rank, and (optionally) restricts to the viewer's
project keys via scope; an ILIKE-token fallback covers recall when FTS finds nothing:
const tsv = sql`to_tsvector('swedish', ${nyhetnPost.title} || ' ' || ${nyhetnPost.body})`;
const tsq = sql`websearch_to_tsquery('swedish', ${query})`;
const fts = await db
.select(cols)
.from(nyhetnPost)
.innerJoin(nyhetnCategory, eq(nyhetnPost.categoryId, nyhetnCategory.id))
.where(and(sql`${tsv} @@ ${tsq}`, scope))
.orderBy(desc(sql`ts_rank(${tsv}, ${tsq})`))
.limit(limit);IMPORTANT —
searchNewsis a plaindb-using function, not acreateServerFn. It lives in its own server-only module (knowledge-search.ts) that the route components never import. A route importing it would drag the Postgres client into the browser bundle and break hydration. The route-facinggetNyhetnFeedis acreateServerFn, which the compiler strips — that's why the two live apart.
See the proactive agent for how the tool is wired into the chat, and AI for the provider-pluggable adapter behind it.
Extending it
- A new category → insert a
nyhetn_categoryrow (give it aslug,color, andsortOrder) in the seed; the filter chips pick it up automatically. - More posts → add
nyhetn_postrows in the seed with aprojectKey,severity, and optionalpinned— there's no create UI, so the seed is the authoring path. Follow Building a feature if you add one. - A new field → add the column in
src/db/schema/nyhetn.ts, surface it in thegetNyhetnFeedselect +NyhetnPostRow, and render it in the route'sPostItem. - Make it searchable → if the field is text the agent should find, add it to the
to_tsvector(and ILIKE fallback) insearchNews— no re-embedding needed.