the finances skeleton — repo, prefix and permission key, before any feature

Published empty on purpose: identity is the expensive part to change later, the
feature is the cheap part to add. One route, two placeholder panels, no tables and
no sidecar.
This commit is contained in:
2026-08-27 17:28:00 +01:00
commit 6e88d849e7
10 changed files with 222 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+40
View File
@@ -0,0 +1,40 @@
# AGENTS.md
Guidance for a coding agent working on **this plugin's own repository**. `CLAUDE.md` beside it only
points here — the two harnesses discover different filenames and one set of instructions has to reach
both.
**This is not what the plugin is.** `README.md` answers that. This file answers "I have to change
something in here — what will I break?"
## Where you are
A plugin repository, cloned into `~/officer-dev/plugins/finances`, its own git remote. It is **not** part
of the platform's repository, and `platform/plugins/finances` is a different thing — that is where this
plugin gets INSTALLED, by the installer cloning it from gitea. Never edit there; the next install
overwrites it.
## What must not change without knowing why
- **Permission key `finances`** — stored as the subject of every grant. Renaming it is a data change.
- **Panel keys** (`finances-overview`, `finances-detail`) — a user's stored layout names them.
- **`appName`** — the directory name IS the id: route segment, table prefix, sidecar suffix, install key.
## Adding tables
There are none yet. When there are, `db/schema.ts`, every table name prefixed `finances_`, and
`bun db:push` from the platform repo. Uninstall never drops a table; deleting this directory and running
`db:push` does.
## Verifying
From the platform repo, which is where the toolchain lives:
```bash
bunx tsgo
bun run plugins:check
bunx prettier --write <the files you touched>
```
A change in `api/` or `web/` needs `pm2 restart officer`. A manifest change needs it too — manifests are
read once per server process.
+6
View File
@@ -0,0 +1,6 @@
# CLAUDE.md
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+31
View File
@@ -0,0 +1,31 @@
# Finances
Money, on your own machine.
**This is a skeleton.** The repository, the mount prefix (`/finances`, `/api/finances`) and the permission
key (`finances`) are real and settled. What the plugin actually does is not written yet — the two panels
render a placeholder and the one route answers `GET /api/finances/ping`.
Published in this state on purpose: the identity of a plugin is the part that is expensive to change
later, and the feature is the part that is cheap to add.
## What it ships
| Part | Present | Note |
| ------------------ | ------- | ------------------------------------------------------------ |
| `manifest.ts` | yes | identity, one permission |
| `api/router.ts` | yes | one route, mounted at `/api/finances` |
| `web/` | yes | two placeholder panels and a layout |
| `db/schema.ts` | no | no tables until the data model is settled |
| `sidecar/` | no | nothing long-lived yet |
| `assets/icon.png` | no | the dock falls back to the manifest's lucide `Wallet` |
## Installing
It is on disk and not installed until you install it from `/plugins`. Being in the tree makes it appear
on that page; it mounts nothing until then.
## Further reading
`plugins/example` in the platform repo is the reference plugin and documents every convention this one
follows.
+6
View File
@@ -0,0 +1,6 @@
import { createRouter } from "@@/create-router";
/** Mounted at `/api/finances`. The prefix comes from the manifest's `publisher`; nothing here sets it. */
export const router = createRouter();
router.get("/ping", (ctx) => ctx.json({ plugin: "finances", ok: true }));
+40
View File
@@ -0,0 +1,40 @@
import type { PluginManifest } from "@@/plugins/manifest";
/**
* Finances.
*
* The skeleton, published before the feature so that the repository, the mount prefix and the permission
* key exist and are stable while the rest is still being decided.
*
* What it does NOT ship yet, and why each is an absence rather than a stub:
*
* db/ no tables until the data model is settled. A table added is easy; a table renamed after
* someone has rows in it is not.
* sidecar/ no process. Nothing here is long-lived yet — an importer or a rates poller would be.
*/
export const manifest: PluginManifest = {
publisher: "officerdev",
version: "2026.08.27.01",
platform: ">=1.0.0",
label: "Finances",
summary: "Money, on your own machine.",
tags: ["Personal"],
icon: "Wallet",
color: "#059669",
/**
* One key gating the whole surface.
*
* Finances is the kind of data where "read" is already a strong grant, so a second key splitting
* read from write would say less than it looks like it says. One, until there is a surface that
* genuinely divides.
*/
permissions: [
{
key: "finances",
label: "Finances",
description: "Accounts, balances and transactions",
},
],
};
+15
View File
@@ -0,0 +1,15 @@
import { useParams } from "react-router";
// The second panel, reading the section from the URL rather than from the panel beside it.
export const FinancesDetail = () => {
const { section } = useParams();
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? "(none)"}</code>
</p>
</div>
);
};
+29
View File
@@ -0,0 +1,29 @@
import { useClient } from "hooks/useClient";
import { useQuery } from "@tanstack/react-query";
// Placeholder. It proves the route mounts and the panel renders, and nothing else.
export const FinancesOverview = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ["finances", "ping"],
queryFn: () =>
client.get<{ plugin: string; ok: boolean }>("/finances/ping"),
});
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Finances</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Nothing here yet — the skeleton is up and mounted.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/finances/ping</div>
{isLoading ? (
<span className="text-duck-dark/40">…</span>
) : (
<span>{JSON.stringify(data)}</span>
)}
</div>
</div>
);
};
+29
View File
@@ -0,0 +1,29 @@
import type { LayoutNode } from "officerdev";
// The starting arrangement. The shell persists whatever the user makes of it, per plugin.
//
// Every `appType` must be a key from `panels.ts` — a mismatch falls back silently instead of rendering
// what was meant.
export const defaultLayout: LayoutNode = {
type: "group",
id: "finances-root",
direction: "horizontal",
children: [
{
node: {
type: "panel",
id: "finances-overview",
appType: "finances-overview",
},
size: 40,
},
{
node: {
type: "panel",
id: "finances-detail",
appType: "finances-detail",
},
size: 60,
},
],
};
+24
View File
@@ -0,0 +1,24 @@
import { Wallet, ListTree } from "lucide-react";
import type { AppRegistryMeta } from "officerdev";
import { FinancesOverview } from "./FinancesOverview";
import { FinancesDetail } from "./FinancesDetail";
// The panels this plugin contributes. The shell renders the screen around them; this plugin never does.
//
// These keys are stored in a user's saved layout, so renaming one is a data change rather than a rename.
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: "finances-overview",
name: "Overview",
icon: Wallet,
component: FinancesOverview,
availableOnPanel: false,
},
{
key: "finances-detail",
name: "Detail",
icon: ListTree,
component: FinancesDetail,
availableOnPanel: false,
},
];