Imported from Evaano/mp-watch (
AGENTS.md). Install upstream withnpx skills add Evaano/mp-watch. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
MP Watch
A public accountability record of Maldivian public figures. Its entire value is that its claims hold up, so correctness outranks speed and features here.
Working agreement
- Work on a branch and open a PR. Do not push to
main: it auto-deploys to a live public URL. - Run
pnpm lintandpnpm buildbefore opening a PR. Both must pass. - Commit messages explain why, and name the trap avoided where one exists.
Commands
pnpm dev # localhost:3000
pnpm build # prerenders every page
pnpm lint
pip install -r scripts/ingest/requirements.txt
python scripts/ingest/validate.py # checks data/*.csv; build_graph runs it first
python scripts/ingest/extract_allowances.py # -> data/premium-payments.csv
python scripts/ingest/majlis_members.py # -> data/majlis-roster.csv + majlis-speakers.csv
python scripts/ingest/rti_20th_majlis.py # -> data/rti-20th-majlis.csv
python scripts/ingest/political_posts.py # -> data/political-posts-*.csv
python scripts/ingest/vip_majlis.py # -> data/vip-*.csv + diplomatic-passports-*.csv
python scripts/ingest/build_graph.py # -> src/data/graph.json + docs/identity-review.md
python scripts/ingest/mirror_photos.py # -> public/members/*.webp + src/data/photo-manifest.json
data/*.csv is the data. Extractors write it, build_graph.py reads it,
and a wrong figure is fixed by editing the cell and rebuilding — no Python, no
re-reading a PDF. Read data/README.md before touching one.
An extractor re-run compares and fails by default; --accept is what
discards hand corrections, and it prints what it is discarding first. Fetched
pages and source PDFs are cached under scripts/ingest/source/ and committed,
so a build reproduces without depending on a government site being up.
The data model
Everything is the same shape: a claim about a person, over a period, with a
source. Spending, income, pledges, delivery, attendance and allegations are
claim types in src/lib/schema.ts, not separate schemas. A new dataset adds a
variant to Claim; it does not add a table.
Claim["sources"]is[SourceRef, ...SourceRef[]], a non-empty tuple. A claim with no source does not compile. Do not weaken this.AllegationClaimrequires astatus. Publishing an allegation without its current status is how this project causes real harm.- The timeline is derived in
registry.timeline(), never stored. src/lib/registry.tsis the only module that reads the graph.
What the money is (read before writing any copy about it)
The premium is priced per covered head and the policy covers the member
and their dependents. src/lib/premium.ts holds the editorial rule;
scripts/ingest/validate.py holds the rates and enforces them.
- MVR 24,000 per head per year from 2016-2017, stated by the RTI disclosure.
- MVR 12,500 for 2014-2016, inferred from the exact GCD of every row in those years. Never present that one as a quotation.
- Every row divides exactly by the rate in force. Use it as an extraction check.
- Never state a head count for a named person. 22 of 93 rows in the RTI period are odd multiples, so cover changed within the period and no integer dependent count is recoverable. Decompose aggregates only.
- Never roll these figures into a salary or income total, and never describe them as money a member received.
Rules that are not negotiable
- Never assert more than the source says. Positions carry
basis: "stated" | "inferred", and inferred ones show their reasoning in the UI. The premium disclosure records payments, not terms of service. - Never auto-merge people on a fuzzy match. The join needs a folded name
match and an exact constituency match, and merges only when unique.
Everything else goes to
docs/identity-review.mdfor a human. A wrong merge attaches one person's spending, votes or allegations to another and nothing downstream reveals it. - Every external comparator carries its source (
src/lib/comparators.ts). No remembered figures, no round numbers chosen because they read well. The same applies to party names and colours (src/lib/parties.ts): the colour name is what a source states, the hex is ours, and two parties publish no colour at all — they get a hollow mark, never an invented one. - Party belongs to a Position, never to a Person. Six independents crossed to PNC within four days of the 2024 election; an undated party label is wrong.
- Spending accessors narrow on
subtype, never ontype.registry.premium()is health-insurance premiums andvip()is airport VIP. "expenditure" is a family: matching on type alone folds every new dataset into the home page headline, each member's lead figure andpartyTotals()at once, and no figure on the site looks wrong. - A political post's pay is an entitlement, never a payment.
PoliticalPostcarriesmeasure: "entitlement", sits in its own top-level array, and has noamountorcurrency. It never passes throughclaims(),expenditure()ortotals(). Do not multiplypostsby a rate anywhere: that produces an expenditure figure no document states, ignoring vacancies, part-months, the Finance deduction and the ministries that never answered post by post.
Traps this codebase has already paid for
Each of these cost real debugging. Do not rediscover them.
Thaana
- PDFs store Thaana in visual order: characters reversed within a word, and
cell words running left to right.
scripts/ingest/thaana.pyundoes both. Beware: naive de-reversal also reverses ASCII digit runs, so a header reading42may be24in the document. - Two government documents spell the same name differently. The roster
writes Mahloof with
ޙ, the disclosure withޚ; one ends Muaz in sukun, the other in u.fold_for_match()folds thikijehi letters to plain counterparts and drops fili. Use it for matching only, never for display. - The site is English only; Thaana lives in the data, not the UI.
nameDvon a Person andconstituencyDvon a Position are the join keys, nothing renders them, andname/constituencyare the Latin display forms. Do not reintroduce Thaana typography: the MV Iyyu face, thelang="dv"size bump and the RTL isolation on.numeralwere all removed with the Dhivehi UI. nameDvis optional. A person whose only source prints no Thaana — a political appointee named in an English spreadsheet — is still a valid Person, and is one that can never be roster-joined.
Tailwind v4
- Tokens declared in
@theme inlineare substituted at definition time and are not usable as runtime CSS variables.font-family: var(--font-mono)resolved to nothing, became invalid at computed-value time and — being an inherited property — silently fell back to the parent font. Reference the variablenext/fontactually sets (--font-mono-latin).
The Majlis sources
- Member ids are reissued every parliament (18th = 1-85, 19th = 86-174, 20th = 175-268). The roster arrives as person-terms; collapse them before anything else joins to them.
- The Latin constituency name drifts between terms ("Hithadhoo Uthuru Dhaaira" becomes "North Hithadhoo") while the Thaana is stable. Join on Thaana.
- The member cards, not the seating-chart JSON, are the roster. The
var dataarray covers only currently-charted seats and is short for older terms. The anchor wraps the card and sits before it, so parsing must be anchor-scoped or every member gets the next member's party. - Number formats vary within one PDF:
12,500,120000.00and-for nil all appear. Match amounts on shape, not on a separator. Keying on the comma silently classified plain-decimal amounts as name text and lost MVR 216,000. - The disclosure's own row numbers are unreliable — 11 repeat, 5 are skipped. Never use them as identity.
- The 20th Majlis VIP PDF reverses its Thaana but not its ASCII. Running
repair_visual_orderover every token turns MVR 3,491.70 into 07.1943 and row 78 into row 87, and both still look like numbers. Repair a token only when it contains Thaana. - A constituency is not unique within a term. The 18th Majlis VIP table lists Dhiggaru twice, for Ahmed Nazim and then Ahmed Faris Maumoon — a mid-term replacement. 86 rows for 85 seats.
Party colour
- The mark is a ringed dot, never coloured text. The ring carries the boundary, which is what lets the fill be the party's actual colour instead of a value dragged dark enough to pass a text-contrast threshold it never needed. An earlier pass made MDP yellow into a brown nobody would recognise.
- The code is always beside the mark, so colour never carries identity on its own. That is what makes PNC and DEM tolerable at dE 23, and PNC tolerable next to the teal site accent.
- The
PositionListtimeline bullet is--line-strong, not--accent. A party mark renders a line below it, and PNC's turquoise against the accent teal was two different meanings in near-identical colours.
Portraits
person.photoUrl points at majlis.gov.mv. Do not render it. Their
Cloudflare serves browsers but answers 403 to Vercel's image optimiser, so
production showed a full grid of grey squares while localhost looked perfect —
the optimiser only runs on Vercel. mirror_photos.py copies each portrait into
public/members/<personId>.webp (320px square, ~1.8 MB for 229 people) and
writes a manifest; photo(id) in registry.ts is the only reader. There is
deliberately no images.remotePatterns entry in next.config.ts — adding one
brings the bug back. A person the mirror could not fetch falls back to the
initial-letter placeholder rather than to the remote URL, so the failure is
visible at ingest time instead of in production.
Re-run the mirror after any roster ingest, or new members render as initials.
Deployment
- The first CLI deploy from the production branch goes to production, with
or without
--prod. Use branches and PRs. - The site is public but noindex until launch. Crawling is deliberately
allowed: a
Disallow: /would stop crawlers reading the noindex, leaving a shared URL indexable with no content. One constant,ALLOW_INDEXINGinsrc/lib/site.ts, drives the meta tag, the header and robots.txt together. registry.primarySource()looks the disclosure up by id, not by index or kind. It wassources[0], which broke when a second ingest was added, then the firstofficial-disclosure, which broke when a second one was registered.- The footer states no per-page source. It used to say every figure traced
to the Majlis premium PDF and link it — untrue on
/appointeesand/vip, which is an unsourced provenance claim in the site's own chrome. Each page carries its own sources block instead.
Known gaps
Read docs/data-sources.md before promising a feature. It records 67 verified
sources and, more importantly, what does not exist:
- No structured campaign-pledge source anywhere. Promised-vs-delivered has a solved delivered side and no promised side.
- Asset declarations are unparseable — 22-page scans of hand-filled Thaana with no text layer. Do not promise structured asset or income data. Filed / not filed / N filings is honest and is itself a real signal.
- ACC data stops at 2021. A four-year hole in the corruption record.
- No constituency-to-island mapping is published. It must be built by hand and blocks every constituency-level rollup.