diff --git a/README.md b/README.md
index 8a364aa..1389120 100644
--- a/README.md
+++ b/README.md
@@ -320,3 +320,122 @@ npm run db:migrate # Create and apply a migration
npm run db:seed # Seed the admin user
npm run db:studio # Open Prisma Studio (GUI)
```
+
+---
+
+## Feature Roadmap
+
+The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase.
+
+---
+
+### 1. Username Display Names & Password Changes on Profile
+
+**Problem:** Usernames are forced lowercase on registration (some users want "JohnDoe" not "johndoe"). Users also have no self-service way to change their password.
+
+**Plan:**
+- Add a `displayUsername` field to `User` — stores the original mixed-case version typed at signup. Lookups/URLs continue using the lowercase `username` for uniqueness and consistency.
+- Profile settings page (`/profile/[username]/settings` or a tab on the existing profile) with:
+ - **Change display name** — updates `displayUsername` only; the canonical `username` remains lowercase and immutable (avoids breaking existing links/positions).
+ - **Change username** — updates both `username` and `displayUsername`; requires confirming no conflicts; all foreign key relations in Prisma use the DB `id` so no cascade issues.
+ - **Change password** — classic current-password + new-password + confirm form; server endpoint `PATCH /api/user/me` validating current hash before updating.
+- All places that show a username (Navbar, profile page, leaderboard, trade history) should render `displayUsername` if set, falling back to `username`.
+
+---
+
+### 2. Leaderboard
+
+**Problem:** No public way to see who the top players are or browse other users' portfolios.
+
+**Plan:**
+- New page `/leaderboard` showing top N players sorted by **net worth** (balance + sum of position value at current prices).
+- Table columns: rank, avatar/username link → their public profile, net worth, total trades, biggest single position.
+- Public profiles — any user can view `/profile/[username]`; sensitive data (exact balance) optionally hidden from non-owners/non-admins.
+- Navbar: show **Leaderboard** link when signed in (replacing or supplementing the current unauthenticated-only links).
+- Consider a separate "all-time best trade" leaderboard tab for engagement.
+
+---
+
+### 3. Lucky Dip Lottery
+
+**Problem:** Players who go broke have no recovery mechanic; there is no daily engagement hook beyond trading.
+
+**Plan:**
+- New page `/lottery`.
+- Once per day (tracked by a `lastLotteryAt` timestamp on `User`), a player who has **$0 or less** can reveal one hidden box from a grid (e.g. 5 × 5 = 25 boxes).
+- One box is the winner (selected server-side at draw time, stored encrypted or revealed only on pick to prevent client-side cheating).
+- **Prizes** (configurable): e.g. one box pays $100, a few pay $10, the rest pay $0.
+- API: `POST /api/lottery/pick` — validates cooldown, selects winner server-side, awards balance, returns outcome.
+- Consider allowing players with any balance to play for a small stake (e.g. pay $5 to play, win up to $200) to make it useful beyond the broke scenario.
+- Future: admin-configurable prize pool and grid size.
+
+---
+
+### 4. Home Page Nav Changes (Signed-In vs Signed-Out)
+
+**Problem:** Home page shows the same call-to-action links regardless of auth state.
+
+**Plan:**
+- When **signed out**: show Sign Up / Sign In CTAs as currently.
+- When **signed in**: replace or supplement with quick links to **Leaderboard**, **Lottery**, and the user's own profile.
+- Navbar already hides/shows some items; extend that logic to the hero section of the home page.
+
+---
+
+### 5. Related Hashtags
+
+**Problem:** No discovery path from one hashtag to related ones.
+
+**Plan:**
+- During price update jobs the worker already fetches up to 200 posts. Parse each post's `tags` array (present in the Mastodon API response) to collect co-occurring hashtags.
+- Store co-occurrence counts in a new `RelatedHashtag` table: `(hashtagId, relatedTag, coOccurrences)` — upsert on each price update, incrementing counts.
+- On the hashtag detail page, show the top 5 most co-occurring tags as "Related" chips that link to their own pages.
+- Only surface related tags that are themselves active (or researchable) — avoid surfacing banned/inactive noise.
+- Worker change: `MastodonPost` type already has `content`; add `tags: { name: string }[]` to the interface and parse it in `mastodon.ts`.
+
+---
+
+### 6. Admin: Price Audit Log
+
+**Problem:** Admins have no visibility into the math behind a given price update.
+
+**Plan:**
+- The `PriceHistory` table already stores `postsPerHour` alongside `price`. Surface this in two places:
+ 1. **Hashtag detail page** — show the most recent `postsPerHour` reading and the formula result as a tooltip or sub-line under the current price (e.g. _"$1.49 — 5.96 posts/hr at last update"_).
+ 2. **Admin stocks page** — add a "History" drawer/modal per hashtag showing the last N `PriceHistory` rows as a table: `recordedAt | postsPerHour | price | Δ price` so admins can see the full audit trail.
+- No schema changes needed — `postsPerHour` is already stored.
+
+---
+
+### 7. Admin: Add Hashtag Manually
+
+**Problem:** Admins cannot bypass the research-point system to seed the exchange with interesting hashtags.
+
+**Plan:**
+- "Add hashtag" button on `/admin/stocks` that opens a modal with a tag input.
+- API: `POST /api/admin/stocks` — normalizes tag, queries Mastodon immediately (same as research flow), upserts hashtag, queues an initial price-update job. Returns error if Mastodon returns nothing.
+- Optionally allow admin to force-add with a manual starting price even if Mastodon has no results (useful for bootstrapping/testing).
+
+---
+
+### 8. Admin: Ban / Block Hashtags
+
+**Problem:** No way to prevent abusive or NSFW hashtags from being researched into the exchange.
+
+**Plan:**
+- Add `isBanned: Boolean @default(false)` field to the `Hashtag` model.
+- Admin UI: "Ban" button on the stocks page that sets `isBanned = true` and `isActive = false`. Banned hashtags still exist in the DB for record-keeping but are not researchable.
+- Research API (`/api/research`): check `isBanned` on any existing matching hashtag — if banned return a `403` with a user-friendly message (e.g. _"This hashtag is not available on HashEx."_) without spending a research point.
+- New pre-ban blocklist: a static list (or admin-editable table) of tags that are auto-banned on first research attempt, before any Mastodon query.
+- Banned tags should still appear in the admin stocks list with a distinct visual indicator and an "Unban" toggle.
+
+---
+
+### Other Ideas / Nice-to-Haves
+
+- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades.
+- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
+- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances.
+- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
+- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
+- **Dark/light theme toggle**: currently dark-only.
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 22fa37d..1c777fe 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -8,14 +8,16 @@ datasource db {
}
model User {
- id String @id @default(cuid())
- username String @unique
- passwordHash String
- balance Float @default(2000)
- researchPoints Int @default(1)
- isAdmin Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ username String @unique // lowercase, used for URLs/lookups
+ displayUsername String? // original casing chosen by user
+ passwordHash String
+ balance Float @default(2000)
+ researchPoints Int @default(1)
+ isAdmin Boolean @default(false)
+ lastLotteryAt DateTime? // tracks daily lottery cooldown
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
positions Position[]
trades Trade[]
@@ -40,18 +42,35 @@ model Hashtag {
displayTag String // original case as entered
currentPrice Float @default(0.25)
isActive Boolean @default(true)
+ isBanned Boolean @default(false)
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
zeroCount Int @default(0)
lastUpdated DateTime @default(now())
createdAt DateTime @default(now())
- priceHistory PriceHistory[]
- positions Position[]
- trades Trade[]
+ priceHistory PriceHistory[]
+ positions Position[]
+ trades Trade[]
+ relatedFrom RelatedHashtag[] @relation("RelatedFrom")
+ relatedTo RelatedHashtag[] @relation("RelatedTo")
@@index([isActive, lastUpdated])
}
+model RelatedHashtag {
+ id String @id @default(cuid())
+ hashtagId String
+ hashtag Hashtag @relation("RelatedFrom", fields: [hashtagId], references: [id], onDelete: Cascade)
+ relatedTag String // lowercase tag name (may not yet be in Hashtag table)
+ relatedId String? // set if the related hashtag exists in the DB
+ related Hashtag? @relation("RelatedTo", fields: [relatedId], references: [id], onDelete: SetNull)
+ coOccurrences Int @default(1)
+ updatedAt DateTime @updatedAt
+
+ @@unique([hashtagId, relatedTag])
+ @@index([hashtagId, coOccurrences])
+}
+
model PriceHistory {
id String @id @default(cuid())
hashtagId String
diff --git a/src/app/admin/stocks/AdminAddHashtag.tsx b/src/app/admin/stocks/AdminAddHashtag.tsx
new file mode 100644
index 0000000..64bb10e
--- /dev/null
+++ b/src/app/admin/stocks/AdminAddHashtag.tsx
@@ -0,0 +1,127 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Plus, Loader2 } from 'lucide-react'
+
+export function AdminAddHashtag() {
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [tag, setTag] = useState('')
+ const [forcePrice, setForcePrice] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [message, setMessage] = useState('')
+
+ async function handleAdd() {
+ setLoading(true)
+ setError('')
+ setMessage('')
+
+ const body: { tag: string; forcePrice?: number } = { tag }
+ if (forcePrice && parseFloat(forcePrice) > 0) {
+ body.forcePrice = parseFloat(forcePrice)
+ }
+
+ const res = await fetch('/api/admin/stocks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const data = await res.json()
+ setLoading(false)
+
+ if (!res.ok) {
+ setError(data.error ?? 'Failed to add hashtag.')
+ } else {
+ setMessage(data.alreadyActive ? `#${tag} is already active.` : `#${tag} added successfully!`)
+ setTag('')
+ setForcePrice('')
+ router.refresh()
+ setTimeout(() => { setOpen(false); setMessage('') }, 1500)
+ }
+ }
+
+ return (
+ <>
+
+
+ {open && (
+
setOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+
Add hashtag
+
+ Queries Mastodon and sets the price automatically. Provide an override price if you
+ want to add a tag with no recent activity.
+