Skip to content
Fusion

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 Post model 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.

LayerWhere it lives
Logic (package)@tikab-interactive/pulsn-apps/nyhetn — schema, the feed read (getNyhetnFeed) + Swedish-FTS searchNews
Transportthin createServerFn wrapper in src/lib/nyhetn-server.ts; the agent's search via src/lib/knowledge-search.ts
UIfusion-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.

Loading diagram...
  • nyhetn_category — the chip you filter by: name, a unique slug, a color, and a sortOrder for the chip row.
  • nyhetn_post — one entry. Carries a title, a Markdown body, a categoryId (FK, onDelete: "cascade"), a free-text building label, an authorName, and publishedAt.
  • Severity & pinningseverity is a nyhetn_severity enum (info / warning / critical) that drives the coloured accent; pinned floats 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

URLWhat it shows
/project/$projectKey/nyhetnthe 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:

example/src/routes/_authed.project.$projectKey.nyhetn.tsx
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.

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:

pulsn-apps/src/nyhetn/index.ts
	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);

IMPORTANTsearchNews is a plain db-using function, not a createServerFn. 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-facing getNyhetnFeed is a createServerFn, 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_category row (give it a slug, color, and sortOrder) in the seed; the filter chips pick it up automatically.
  • More posts → add nyhetn_post rows in the seed with a projectKey, severity, and optional pinned — 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 the getNyhetnFeed select + NyhetnPostRow, and render it in the route's PostItem.
  • Make it searchable → if the field is text the agent should find, add it to the to_tsvector (and ILIKE fallback) in searchNews — no re-embedding needed.