Personal portfolio tracker built with Next.js 16, Drizzle ORM, and Neon Postgres. Tracks ETF and crypto positions with live market quotes (Yahoo Finance for ETFs, CoinGecko for crypto), P/L calculations, cash balances, and automated daily return snapshots.
- Next.js 16 (App Router) + React 19
- Tailwind CSS v4 (dark-only UI)
- Drizzle ORM + Neon Postgres (
@neondatabase/serverless) - Yahoo Finance + CoinGecko + Frankfurter for market data
- Recharts for return and portfolio history charts
- iron-session + bcryptjs for password auth
-
Clone the repository and install dependencies:
npm install
-
Copy environment variables:
cp .env.example .env.local
Fill in:
Variable Description DATABASE_URLNeon Postgres connection string SESSION_SECRETRandom string, 32+ characters CRON_SECRETBearer token for cron snapshot endpoint SEED_USER_EMAILFirst account email (seed script only) SEED_USER_PASSWORDFirst account password (seed script only; stored hashed in DB) -
Push the database schema:
npm run db:push
-
Seed reference data and create your login account:
npm run db:seed
Set
SEED_USER_EMAILandSEED_USER_PASSWORDin.env.localbefore running seed. The password is hashed with bcrypt and stored in theuserstable. Re-running seed does not overwrite an existing account.Add your positions from the portfolio UI after signing in.
-
Start the dev server:
npm run dev
Open http://localhost:3000.
-
Push the code to GitHub.
-
Import the project in Vercel.
-
Create a Neon Postgres database (Vercel Storage integration or neon.tech) and set
DATABASE_URL. -
Add environment variables in the Vercel project settings:
SESSION_SECRETCRON_SECRET
-
Deploy. On first deploy, push the schema:
npx drizzle-kit push
Or use the Vercel CLI against your production database.
-
Seed reference data and the first user on production (if not already present):
DATABASE_URL="..." SEED_USER_EMAIL="..." SEED_USER_PASSWORD="..." npm run db:seed
-
Vercel Cron (configured in
vercel.json):- Every 30 minutes — refresh market quotes from Yahoo Finance / CoinGecko
- 22:00 UTC — daily snapshot at midnight Europe/Rome (CEST: 00:00 Rome; CET: 23:00 Rome)
Vercel sends
Authorization: Bearer <CRON_SECRET>automatically whenCRON_SECRETis set. Cron jobs that run more often than once per day require a Vercel Pro plan; with Hobby, quotes still auto-refresh every 30 minutes while an admin has the app open.
Two roles control what a logged-in user can do:
| Role | Access |
|---|---|
admin |
Full read/write on portfolio, cash, and price refresh. Can manage users. |
viewer |
Read-only: portfolio, cash, returns, flows, and cached diagnostics. Cannot edit positions, cash, refresh prices, or manage users. |
The portfolio is shared — all users see the same data. Roles only control whether edits are allowed.
- Sign in as an admin (your seed account is created with
role: admin). - Open Users in the navigation (
/settings/users). - Create a new user with role Viewer (read-only) and a dedicated password.
- Share the credentials with your analyst. They will see a Read-only badge in the header.
Write operations are blocked server-side for viewers, not only in the UI.
- Snapshots store positions value only (ETF + crypto). Cash is excluded from returns and tracked separately on
/cash. - One snapshot per calendar day (
daily_snapshots.dateprimary key). - Daily return for day D = snapshot(D+1) − snapshot(D) − net capital flows on D. Flows are recorded when you change load value (buy/sell) or add/remove positions; each row stores the position title, EUR amount, and share delta.
startValueEuris snapshot(D),endValueEuris snapshot(D+1);returnEurandreturnPctare derived in code, not stored. return_pctis a decimal ratio (e.g.0.00535= 0.54%). The UI formats it for display.- Today uses the latest snapshot as start and live portfolio value as end until the next midnight snapshot closes the day.
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Production build |
npm run db:generate |
Generate Drizzle migrations |
npm run db:push |
Push schema to database |
npm run db:seed |
Seed exchanges, FX quote source, and first user |
npm run db:check |
Verify database connectivity |
npm run db:backup |
Dump database to backups/thrust-YYYY-MM-DD.dump |
Thrust does not back up the database by itself. To keep a local copy on your Mac every day:
brew install libpq
echo 'export PATH="/opt/homebrew/opt/libpq/bin:$PATH"' >> ~/.zshrc
source ~/.zshrcnpm run db:backupThis writes a compressed dump to backups/thrust-YYYY-MM-DD.dump (gitignored). The script uses DATABASE_URL from .env.local and automatically switches from Neon's pooler host to the direct endpoint required by pg_dump.
Old dumps are deleted after 30 days (RETENTION_DAYS env var overrides this).
REPO="/Users/x/Desktop/code/thrust"
sed "s|REPLACE_WITH_REPO_PATH|$REPO|g" scripts/com.thrust.db-backup.plist.example \
> ~/Library/LaunchAgents/com.thrust.db-backup.plist
launchctl load ~/Library/LaunchAgents/com.thrust.db-backup.plistDefault time: 12:00 every day. Edit the plist to change the hour.
Check it ran:
tail -f backups/backup.log
launchctl list | grep thrustUnload:
launchctl unload ~/Library/LaunchAgents/com.thrust.db-backup.plistpg_restore --clean --if-exists --no-owner --no-acl \
-d "$DATABASE_URL" backups/thrust-2026-07-03.dumpWarning: restore overwrites data on your single Neon database. Test on a Neon branch first if unsure.
Neon Console → Backup & restore → Edit schedule → Daily gives you snapshots on Neon's side even when your Mac is off. Use both for belt-and-suspenders: Neon for disaster recovery, local dumps for a file you control.
/— Portfolio dashboard with metrics, allocation donut, and editable positions table/cash— Cash balance management (not included in return calculations)/returns— Daily/weekly return charts and positions value history/flows— Capital flow history (buys, sells, new positions)/settings— Ticker diagnostics and cached price status/settings/users— User management (admin only)/login— Email + password login
GET /api/quotes— Fetch cached quotesPOST /api/quotes— Refresh quotes from market data providersGET /api/returns?period=day|week&from=&to=— Return history and chart snapshotsGET /api/cron/quotes— Refresh all position quotes (Bearer auth, every 30 min on Pro)GET /api/cron/snapshot— Capture midnight snapshot and recompute returns (Bearer auth)