initial commit

This commit is contained in:
Nico 2026-02-11 23:45:15 -08:00
commit 4873449e16
185 changed files with 21374 additions and 0 deletions

View File

@ -0,0 +1,69 @@
name: Build & Deploy Fiddy
on:
push:
branches: [ "main" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/fiddy
IMAGE_TAG: main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build Web Image
run: |
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:${{ env.IMAGE_TAG }} -f docker/Dockerfile .
- name: Push Web Image
run: |
docker push $REGISTRY/web:${{ github.sha }}
docker push $REGISTRY/web:${{ env.IMAGE_TAG }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Upload docker-compose.yml
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/fiddy"
scp docker-compose.yml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/fiddy/docker-compose.yml
- name: Deploy via SSH
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
cd /opt/fiddy
export IMAGE_TAG=main
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
EOF

123
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,123 @@
# Copilot Instructions — Fiddy (External DB)
## Source of truth
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
## Stack
- Monorepo (npm workspaces)
- Next.js (App Router) + TypeScript + Tailwind
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
- Docker Compose dev/prod
- Gitea + act-runner CI/CD
## Environment
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
- `DATABASE_URL` points to the external DB server (NOT a container).
## Auth
- Custom email/password auth.
- Use HttpOnly session cookies backed by DB table `sessions`.
- NEVER trust client-side RBAC checks.
## Receipts
- Store receipt images in Postgres `bytea` table `receipts`.
- Entries list endpoints must not return image bytes.
- Image bytes only fetched by separate endpoint when inspecting a single item.
## UI
- Dark mode, minimal, mobile-first.
- Dodger Blue accent (#1E90FF).
- Top navbar: left nav dropdown, middle group selector, right user menu.
## Code Rules
- Small files, minimal comments.
- Prefer single-line `if` without braces when only one line follows.
- Heavy logic lives in components/hooks/services, not page files.
- Prefer API calls via exported hooks/services for reusability and cleanliness (avoid raw fetch in components when possible).
- Add/update unit tests with changes (TDD).
- Heavy focus on code readability and maintainability; prioritize clean code over clever code.
- ie. Separating different concerns into different files, and adding helper functions to avoid nested logic and improve readability, is preferred over clever one-liners or consolidating logic into fewer files.
- ie. Separate groups of related codes by adding 3 line breaks between them
## Data Model
- Users (system_role USER|SYS_ADMIN)
- Groups + membership (group_role MEMBER|GROUP_ADMIN)
- Entries (group-scoped) + optional receipt_id
- User settings (jsonb)
- Reports for system admins
## Architecture Contract (Backend ↔ Client ↔ Hooks ↔ UI)
### No-Assumptions Rule (Required)
- Before making structural changes, first scan the repo and identify:
- the web app root (where `app/`, `components/`, `hooks/`, `lib/` live)
- existing API routes and helpers
- existing patterns already in use
- Do not invent files, endpoints, or conventions. If something is missing, add it minimally and consistently.
### Layering (Hard Boundaries)
For every domain (auth, groups, entries, receipts, etc.), keep a consistent 4-layer flow:
1) **API Route Handlers** (`app/api/.../route.ts`)
- Thin: parse input, call a server service, return JSON.
- No direct DB queries inside route files unless there is no existing server service.
- Must enforce auth & membership checks on server.
2) **Server Services (DB + authorization)** (`lib/server/*`)
- Own all DB access and authorization helpers (sessions, requireGroupMember, etc.).
- Server-only modules must include `import "server-only";`
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/session.ts`.
3) **Client API Wrappers** (`lib/client/*`)
- Typed fetch helpers only (no React state).
- Centralize `fetchJson()` / error normalization.
- Always send credentials (cookies) and never trust client-side RBAC.
4) **Hooks (UI-facing API layer)** (`hooks/use-*.ts`)
- Hooks are the primary interface for components/pages to call APIs.
- Components should not call `fetch()` directly unless theres a strong reason.
### Domain Blueprint (Consistency Rule)
For any new feature/domain, prefer:
- `app/api/<domain>/...`
- `lib/server/<domain>.ts`
- `lib/client/<domain>.ts`
- `hooks/use-<domain>.ts`
- `components/<domain>/*`
- `__tests__/<domain>.test.ts`
### API Conventions
- Prefer consistent JSON response shape for errors:
- `{ error: { code: string, message: string } }`
- Validate inputs at the route boundary (basic shape/type), and validate authorization in server services.
- When adding endpoints, mirror existing REST style used in the project.
### Non-Regression Contracts (Do Not Break)
- Entries list endpoints must **never** include receipt image bytes; image bytes are fetched via a separate endpoint only.
- Auth is DB-backed HttpOnly sessions; all auth checks are server-side.
- Groups require server-side membership checks; active group persists per user.
- Group invite codes:
- shown once immediately after group creation
- modal renders outside navbar/header so it overlays the viewport correctly
- avoid re-exposing invite code elsewhere without explicit “group settings” work
### UI Structure
- Page files stay thin; heavy logic stays in hooks/services/components.
- Dark mode, minimal, mobile-first.
- Dodger Blue accent (#1E90FF).
- Navbar: left nav dropdown, middle group selector, right user menu.
### Environment
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
- `DATABASE_URL` points to external Postgres (NOT a container).
- `DATABASE_URL` format must be a full connection string; URL-encode special chars in passwords.
### Tests (Required)
- Add/update tests for API behavior changes:
- auth
- groups
- entries (group scoping)
- Tests must include negative cases: unauthorized, not-a-member, invalid inputs.

101
.gitignore vendored Normal file
View File

@ -0,0 +1,101 @@
node_modules
.next
dist
.env
db.env
*.log
.DS_Store
.vscode/*
!.vscode/settings.json
# =========================
# Dependencies
# =========================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# =========================
# Next.js build output
# =========================
.next/
out/
# =========================
# Production build output (common)
# =========================
dist/
build/
# =========================
# Logs
# =========================
logs/
*.log
# =========================
# Runtime / PID files
# =========================
pids/
*.pid
*.seed
*.pid.lock
# =========================
# Env files (secrets)
# =========================
.env
.env.*
!.env.example
!.env.local.example
# =========================
# Local config / caches
# =========================
.cache/
.tmp/
temp/
tmp/
# =========================
# Testing
# =========================
coverage/
.nyc_output/
playwright-report/
test-results/
.cypress/
*.lcov
# =========================
# TypeScript
# =========================
*.tsbuildinfo
# =========================
# Lint / format caches
# =========================
.eslintcache
.stylelintcache
# =========================
# Vercel / Netlify / Deploy
# =========================
.vercel/
.netlify/
# =========================
# OS / Editor
# =========================
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# VS Code
.vscode/*
!.vscode/extensions.json
!.vscode/settings.js

137
DEBUGGING_INSTRUCTIONS.md Normal file
View File

@ -0,0 +1,137 @@
# Debug Protocol (Repo)
> You are debugging an issue in this repo. Do **not** implement features unless required to fix the bug.
## 0) Non-negotiables
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If anything conflicts, follow it.
- **No assumptions:** First scan the repo for existing patterns, locations, scripts, helpers, and conventions. Dont invent missing files/endpoints unless truly necessary—and if needed, add minimally and consistently.
- **External DB:** `DATABASE_URL` points to on-prem Postgres (**not** a container). Dev/Prod share schema via migrations in `packages/db/migrations`.
- **Security:** Never log secrets, full invite codes, or receipt bytes. Invite codes in logs/audit: **last4 only**.
- **No cron/worker jobs:** Any fix must work without background tasks.
- **Server-side RBAC only:** Client checks are UX only.
---
## 1) Restate the bug precisely
Start by writing a 46 line **Bug Definition**:
- **Expected behavior**
- **Actual behavior**
- **Where it happens** (page / route / service)
- **Impact** (blocker? regression? edge case?)
If any of these are missing, ask for them explicitly.
---
## 2) Collect an evidence bundle before changing code
Before editing anything, request/locate:
### A) Runtime signals
- Exact error text + stack trace (server + browser console)
- Network capture: request URL, method, status, response body
- Any `request_id` returned by the API for failing calls
### B) Repo + environment
- Current branch/commit
- How app is started (`docker-compose` + which file)
- Node version + package manager used
- Relevant env vars (sanitized): `DATABASE_URL` **host/port/dbname only** (no password)
### C) Involved code paths
Identify actual files by searching the repo:
- The page/component triggering the request
- The hook used (`hooks/use-*.ts`)
- The client wrapper (`lib/client/*`)
- The API route (`app/api/**/route.ts`)
- The server service (`lib/server/*.ts`)
**Do not guess file names. Use repo search.**
---
## 3) Determine failure class (choose ONE primary)
Pick the most likely bucket and focus there first:
- DB connectivity / docker networking
- Migrations/schema mismatch
- Auth/session cookie flow (HttpOnly cookies, session table, logout)
- RBAC / group membership / active group
- Request validation / parsing (route boundary)
- Client fetch wrapper / hook state
- UI behavior / event handling / mobile UX
- Playwright test failure (timing, selectors, baseURL, auth state)
Write a **35 bullet** hypothesis list ordered by likelihood.
---
## 4) Reproduce locally with the smallest surface area
Prefer reproducing via:
1) A single API call (`curl` / minimal `fetch`) before full UI if possible
2) Then UI repro
3) Then Playwright repro (if its a test failure)
If scripts are needed, inspect `package.json` for actual script names (**dont assume**). Common ones may exist (`lint`, `test`, `db:migrate`) but confirm.
---
## 5) Add targeted observability (minimal + removable)
If evidence is insufficient, add temporary, minimal logs in **server services** (not in UI):
- Always include `request_id`
- Log only safe metadata (`user_id`, `group_id`, route name, timing)
- Never log secrets/full invite code/receipt bytes/passwords/tokens
- If touching invite logic: log **last4 only**
Prefer:
- One log line at service entry (inputs summary)
- One log line at decision points (auth fail / membership fail / db row missing)
- One log line at service exit (success + timing)
Remove or downgrade noisy logs before final PR unless clearly valuable.
---
## 6) Fix strategy rules
- Make the smallest change that resolves the bug.
- Respect layering:
- **Route:** parse + validate shape
- **Server service:** DB + authz + business rules
- **Client wrapper:** typed fetch + error normalization
- **Hook:** UI-facing API layer
- Dont introduce new dependencies unless absolutely necessary.
- Keep touched files free of TS warnings and lint errors.
---
## 7) Verification checklist (must do)
After the fix:
- Re-run the minimal repro (API and/or UI)
- Run relevant tests (unit + Playwright if applicable)
- Add/adjust tests for the bug:
- At least **1 positive** + **2 negatives** (unauthorized, not-a-member, invalid input)
- Confirm contracts:
- Spendings list never includes receipt bytes
- Sessions are HttpOnly + DB-backed
- Audit logs include `request_id` and never store full invite code
---
## 8) Next.js route params checklist (required)
For `app/api/**/[param]/route.ts`:
- Treat `context.params` as **async** and `await` it before reading properties.
- If you see errors about sync dynamic APIs, update handlers to unwrap params:
Example:
```ts
const { id } = await context.params;

50
PROJECT_INSTRUCTIONS.md Normal file
View File

@ -0,0 +1,50 @@
# Project Instructions — Fiddy (External DB)
## Core expectation
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
## Decisions / constraints (Group Settings)
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
- Both owner and admins can approve join requests and manage invite links.
- Invite links:
- TTL limited to 17 days.
- Settings are immutable after creation (policy, single-use, etc.).
- Single-use does not override approval-required.
- Expired links are retained and can be revived.
- Single-use links are deleted after successful use.
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
- API must generate `request_id` and return it in responses; audit logs must include it.
- Audit logs must never store full invite codes (store last4 only).
## Do first (vertical slice)
1) DB migrate command + schema
2) Register/Login/Logout (custom sessions)
3) Protected dashboard page
4) Group create/join + group switcher (approval-based joins + optional join disable)
5) Entries CRUD (no receipt bytes in list)
6) Receipt upload/download endpoints
7) Settings + Reports
## Definition of done
- Works via docker-compose.dev.yml with external DB
- Migrations applied via `npm run db:migrate`
- Tests + lint pass
- RBAC enforced server-side
- No large files
- No TypeScript warnings or lint errors in touched files
- No new cron/worker dependencies unless explicitly approved
## Desktop + mobile UX checklist (required)
- Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile.
- Modal overlays must close on outside click/tap.
- Use bubble notifications for main actions (create/update/delete/join).
- Add Playwright UI tests for new UI features and critical flows.
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
## PR review checklist
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
- No TypeScript warnings or lint errors introduced.

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# Fiddy (External DB)
Monorepo scaffold for a budgeting/collaboration app that connects to an external on-prem Postgres server.
## Quick start (dev)
1. Copy env template:
- `.env.example` -> `apps/web/.env`
2. Edit `apps/web/.env`:
- Set `DATABASE_URL` to your DB server hostname/IP (NOT `db`).
- Set `ALLOWED_DB_NAMES` to a comma-separated allowlist (case-insensitive).
3. Start web:
- `docker compose -f docker-compose.dev.yml up --build`
4. Apply migrations:
- `npm run db:migrate` (ensure `DATABASE_URL` points to the DB you want)
## Prod
- Deploy folder: `/opt/fiddy`
- Put `.env` in `/opt/fiddy/apps/web/.env` with the prod DB connection string and `ALLOWED_DB_NAMES`.
- `docker compose up -d` will run only the web container; DB is external.
## Copilot
- See `copilot-instructions.md` and `PROJECT_INSTRUCTIONS.md`

View File

@ -0,0 +1,130 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { createSession, hashPassword, verifyPassword } from "../lib/server/auth";
import { loginUser, registerUser } from "../lib/server/auth-service";
import getPool from "../lib/server/db";
import { cleanupTestData, cleanupTestDataFromPool } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("password hashing works", async () => {
const hash = await hashPassword("test-password");
assert.ok(await verifyPassword("test-password", hash));
assert.ok(!(await verifyPassword("wrong", hash)));
});
test("createSession inserts row", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error)
t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
const email = `test_${Date.now()}@example.com`;
try {
const { rows } = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[email, await hashPassword("test-password")]
);
const userId = rows[0].id as number;
const session = await createSession(userId);
const { rows: sessionRows } = await client.query(
"select id from sessions where user_id=$1 and expires_at > now()",
[userId]
);
assert.ok(session.token.length > 10);
assert.ok(sessionRows.length === 1);
} finally {
await cleanupTestData(client, { emails: [email] });
client.release();
}
});
test("createSession supports ttl override", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error)
t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
const email = `test_${Date.now()}_ttl@example.com`;
const ttlMs = 60 * 60 * 1000;
const nowMs = Date.now();
try {
const { rows } = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[email, await hashPassword("test-password")]
);
const userId = rows[0].id as number;
const session = await createSession(userId, { ttlMs });
const { rows: sessionRows } = await client.query(
"select expires_at from sessions where user_id=$1 order by created_at desc limit 1",
[userId]
);
const dbExpiresAt = new Date(sessionRows[0].expires_at).getTime();
const expectedMin = nowMs + ttlMs - 5000;
const expectedMax = nowMs + ttlMs + 5000;
assert.ok(session.expiresAt.getTime() >= expectedMin);
assert.ok(session.expiresAt.getTime() <= expectedMax);
assert.ok(dbExpiresAt >= expectedMin);
assert.ok(dbExpiresAt <= expectedMax);
} finally {
await cleanupTestData(client, { emails: [email] });
client.release();
}
});
test("loginUser respects remember flag", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const email = `remember_${Date.now()}@example.com`;
const password = "test-password";
const result = await registerUser({ email, password, displayName: "" });
try {
const rememberTrue = await loginUser({ email, password, remember: true });
const rememberFalse = await loginUser({ email, password, remember: false });
assert.ok(rememberTrue.session.ttlMs > rememberFalse.session.ttlMs);
} finally {
const pool = getPool();
await cleanupTestDataFromPool(pool, { userIds: [result.user.id] });
}
});
test("registerUser rejects duplicate email", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const email = `dup_${Date.now()}@example.com`;
const password = "test-password";
const mixedCase = email.toUpperCase();
const result = await registerUser({ email, password, displayName: "" });
try {
await assert.rejects(
() => registerUser({ email: mixedCase, password, displayName: "" }),
{ message: "EMAIL_EXISTS" }
);
} finally {
await cleanupTestDataFromPool(pool, { userIds: [result.user.id] });
}
});

View File

@ -0,0 +1,65 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { calculateBucketUsage } from "../lib/shared/bucket-usage";
const today = "2026-02-11";
test("calculateBucketUsage matches tag subset", () => {
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries", "weekly", "extra"], isRecurring: false, entryType: "SPENDING" as const },
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["groceries"], isRecurring: false, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
assert.equal(result.totalUsage, 20);
assert.equal(result.matchedCount, 1);
});
test("calculateBucketUsage excludes recurring entries", () => {
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
assert.equal(result.totalUsage, 0);
assert.equal(result.matchedCount, 0);
});
test("calculateBucketUsage applies windowDays filtering", () => {
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
const entries = [
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
assert.equal(result.totalUsage, 20);
assert.equal(result.matchedCount, 2);
});
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const },
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
assert.equal(result.totalUsage, 20);
assert.equal(result.matchedCount, 2);
});
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
const entries = [
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH", tags: [], isRecurring: false, entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY", tags: [], isRecurring: false, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
assert.equal(result.totalUsage, 5);
assert.equal(result.matchedCount, 1);
});

View File

@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { createBucket, deleteBucket, listBuckets, updateBucket } from "../lib/server/buckets";
import { ensureTagsForGroup } from "../lib/server/tags";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("buckets CRUD", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`bucket_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Buckets Test", uniqueInviteCode("B"), userId]
);
groupId = groupRes.rows[0].id as number;
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
[groupId, userId]
);
await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] });
const bucket = await createBucket({
groupId,
userId,
name: "Groceries",
description: "Weekly groceries",
iconKey: "food",
budgetLimitDollars: 200,
tags: ["groceries", "weekly"]
});
const list = await listBuckets(groupId);
assert.equal(list.length, 1);
assert.equal(list[0].id, bucket.id);
assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]);
const updated = await updateBucket({
id: bucket.id,
groupId,
userId,
name: "Groceries+",
description: "Updated",
iconKey: "food",
budgetLimitDollars: 250,
tags: ["groceries"]
});
assert.ok(updated);
assert.equal(updated?.name, "Groceries+");
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
await deleteBucket({ id: bucket.id, groupId });
const listAfter = await listBuckets(groupId);
assert.equal(listAfter.length, 0);
} finally {
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});

View File

@ -0,0 +1,5 @@
import { test } from "node:test";
test("entries CRUD (covered by legacy test file)", async t => {
t.skip("Covered by spendings.test.ts after pivot");
});

View File

@ -0,0 +1,149 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { joinGroup, requireActiveGroup } from "../lib/server/groups";
import { setGroupSettings } from "../lib/server/group-settings";
import { transferOwnership } from "../lib/server/group-members";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("join policy enforcement and join requests", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
const inviteCode = uniqueInviteCode("J");
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`owner_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`member_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Join Policy Group", inviteCode, ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" });
await assert.rejects(
() => joinGroup(memberId!, inviteCode),
{ message: "JOIN_NOT_ACCEPTING" }
);
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" });
await assert.rejects(
() => joinGroup(memberId!, inviteCode),
{ message: "JOIN_PENDING" }
);
const pendingRes = await client.query(
"select status from group_join_requests where group_id=$1 and user_id=$2",
[groupId, memberId]
);
assert.equal(pendingRes.rows[0]?.status, "PENDING");
await client.query("delete from group_join_requests where group_id=$1 and user_id=$2", [groupId, memberId]);
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "AUTO_ACCEPT" });
const group = await joinGroup(memberId!, inviteCode);
assert.equal(Number(group.id), groupId);
const memberRows = await client.query(
"select role from group_members where group_id=$1 and user_id=$2",
[groupId, memberId]
);
assert.equal(memberRows.rows[0]?.role, "MEMBER");
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();
}
});
test("ownership transfer updates roles", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`owner2_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`member2_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Ownership Group", uniqueInviteCode("O"), ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
[groupId, memberId]
);
await transferOwnership({
actorUserId: ownerId,
groupId,
newOwnerUserId: memberId,
requestId: `req_${Date.now()}`
});
const roles = await client.query(
"select user_id, role from group_members where group_id=$1",
[groupId]
);
const roleMap = new Map(roles.rows.map(row => [Number(row.user_id), row.role]));
assert.equal(roleMap.get(ownerId), "GROUP_ADMIN");
assert.equal(roleMap.get(memberId), "GROUP_OWNER");
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();
}
});

View File

@ -0,0 +1,118 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("group settings update and read", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let groupId: number | null = null;
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`settings_owner_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Settings Group", uniqueInviteCode("S"), ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
const initial = await getGroupSettings(groupId);
assert.equal(initial.allowMemberTagManage, false);
assert.equal(initial.joinPolicy, "NOT_ACCEPTING");
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" });
const updated = await getGroupSettings(groupId);
assert.equal(updated.allowMemberTagManage, true);
assert.equal(updated.joinPolicy, "AUTO_ACCEPT");
await setGroupSettings({ userId: ownerId, groupId, allowMemberTagManage: false, joinPolicy: "APPROVAL_REQUIRED" });
const updatedAgain = await getGroupSettings(groupId);
assert.equal(updatedAgain.allowMemberTagManage, false);
assert.equal(updatedAgain.joinPolicy, "APPROVAL_REQUIRED");
} finally {
await cleanupTestData(client, { userIds: [ownerId], groupId });
client.release();
}
});
test("group settings require admin", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`settings_owner_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`settings_member_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Settings Perms", uniqueInviteCode("P"), ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
[groupId, memberId]
);
await assert.rejects(
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
{ message: "FORBIDDEN" }
);
const settings = await getGroupSettings(groupId);
assert.equal(settings.allowMemberTagManage, false);
assert.equal(settings.joinPolicy, "NOT_ACCEPTING");
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();
}
});

View File

@ -0,0 +1,109 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { setActiveGroupForUser } from "../lib/server/groups";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("create group inserts membership", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`group_${Date.now()}@example.com`, "hash"]
);
userId = Number(userRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Test Group", uniqueInviteCode("G"), userId]
);
groupId = groupRes.rows[0].id as number;
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, userId]
);
const { rows } = await client.query(
"select role from group_members where group_id=$1 and user_id=$2",
[groupId, userId]
);
assert.equal(rows[0].role, "GROUP_OWNER");
} finally {
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});
test("setActiveGroupForUser stores active group", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
let otherUserId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`active_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const otherRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`active_other_${Date.now()}@example.com`, "hash"]
);
otherUserId = Number(otherRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Active Group", uniqueInviteCode("A"), userId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
[groupId, userId]
);
await setActiveGroupForUser(userId, groupId);
const { rows } = await client.query(
"select data->>'activeGroupId' as active_group_id from user_settings where user_id=$1",
[userId]
);
assert.equal(Number(rows[0]?.active_group_id || 0), groupId);
await assert.rejects(
() => setActiveGroupForUser(otherUserId!, groupId!),
{ message: "FORBIDDEN" }
);
} finally {
await cleanupTestData(client, { userIds: [otherUserId, userId], groupId });
client.release();
}
});

View File

@ -0,0 +1,128 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { acceptInviteLink, createInviteLink } from "../lib/server/group-invites";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("invite link auto-accept adds member", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`invite_owner_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`invite_member_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Invite Group", uniqueInviteCode("I"), ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
const link = await createInviteLink({
userId: ownerId,
groupId,
policy: "AUTO_ACCEPT",
singleUse: false,
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
requestId: "test-request"
});
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
assert.equal(result.status, "JOINED");
const { rowCount } = await client.query(
"select 1 from group_members where group_id=$1 and user_id=$2",
[groupId, memberId]
);
assert.equal(rowCount, 1);
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();
}
});
test("invite link rejects expired link", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let ownerId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
try {
const ownerRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`invite_owner_${Date.now()}@example.com`, "hash"]
);
ownerId = Number(ownerRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`invite_member_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Invite Group", uniqueInviteCode("E"), ownerId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, ownerId]
);
const link = await createInviteLink({
userId: ownerId,
groupId,
policy: "AUTO_ACCEPT",
singleUse: false,
expiresAt: new Date(Date.now() - 60 * 1000),
requestId: "test-request"
});
await assert.rejects(
() => acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" }),
{ message: "INVITE_EXPIRED" }
);
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();
}
});

View File

@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { createRecurringEntry, deleteRecurringEntry, listRecurringEntries } from "../lib/server/recurring-entries";
import { ensureTagsForGroup } from "../lib/server/tags";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("recurring entries list", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`recurring_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Recurring Test", uniqueInviteCode("R"), userId]
);
groupId = groupRes.rows[0].id as number;
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
[groupId, userId]
);
await ensureTagsForGroup({ userId, groupId, tags: ["rent"] });
await createRecurringEntry({
groupId,
userId,
entryType: "SPENDING",
amountDollars: 900,
occurredAt: "2026-02-01",
necessity: "NECESSARY",
purchaseType: "Rent",
notes: "Monthly rent",
tags: ["rent"],
isRecurring: true,
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
nextRunAt: "2026-02-01"
});
const list = await listRecurringEntries(groupId);
assert.equal(list.length, 1);
assert.equal(list[0].isRecurring, true);
} finally {
if (groupId) {
const list = await listRecurringEntries(groupId);
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
}
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});

View File

@ -0,0 +1,67 @@
// __tests__/run-tests.cjs
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const dotenv = require("dotenv");
function walk(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.isFile() && entry.name.endsWith(".test.ts")) out.push(full);
}
return out;
}
const cwd = process.cwd();
dotenv.config({ path: path.join(cwd, ".env") });
const testsDir = path.join(cwd, "__tests__");
const testFiles = fs.existsSync(testsDir) ? walk(testsDir) : [];
const inferredAllowedDbNames = (() => {
if (process.env.ALLOWED_DB_NAMES) return process.env.ALLOWED_DB_NAMES;
if (!process.env.DATABASE_URL) return undefined;
try {
const url = new URL(process.env.DATABASE_URL);
const name = url.pathname.replace(/^\/+/, "");
return name ? decodeURIComponent(name) : undefined;
} catch {
return undefined;
}
})();
if (testFiles.length === 0) {
console.error("No .test.ts files found under __tests__/");
process.exit(1);
}
// Run Node's test runner, with tsx registered so TS can execute.
// Node supports registering tsx via --import=tsx. :contentReference[oaicite:2]{index=2}
const nodeArgs = [
"--require",
"./__tests__/server-only.cjs",
"--import",
"tsx",
"--test-concurrency=1",
"--test",
// Optional nicer output:
// "--test-reporter=spec",
...testFiles,
];
const env = {
...process.env,
NODE_ENV: "test",
NODE_PATH: path.join(cwd, "__tests__", "node_modules"),
...(inferredAllowedDbNames ? { ALLOWED_DB_NAMES: inferredAllowedDbNames } : {}),
};
const r = spawnSync(process.execPath, nodeArgs, {
stdio: "inherit",
cwd,
env,
shell: false,
});
process.exit(r.status ?? 1);

View File

@ -0,0 +1,18 @@
export async function resolve(specifier, context, defaultResolve) {
if (specifier === "server-only")
return {
url: "data:text/javascript,export default {}",
shortCircuit: true,
};
return defaultResolve(specifier, context, defaultResolve);
}
export async function load(url, context, defaultLoad) {
if (url.startsWith("data:text/javascript"))
return {
format: "module",
source: "export default {}",
shortCircuit: true,
};
return defaultLoad(url, context, defaultLoad);
}

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,17 @@
const Module = require("module");
const path = require("path");
const originalLoad = Module._load;
const originalResolve = Module._resolveFilename;
const stubPath = path.join(__dirname, "server-only-stub.js");
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === "server-only" || request.startsWith("server-only/"))
return stubPath;
return originalResolve.call(this, request, parent, isMain, options);
};
Module._load = function (request, parent, isMain) {
if (request === "server-only" || request.startsWith("server-only/"))
return {};
return originalLoad(request, parent, isMain);
};

View File

@ -0,0 +1,98 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { createEntry, deleteEntry, listEntries, updateEntry } from "../lib/server/entries";
import { ensureTagsForGroup } from "../lib/server/tags";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("entries CRUD", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`entry_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Entries Test", uniqueInviteCode("E"), userId]
);
groupId = groupRes.rows[0].id as number;
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
[groupId, userId]
);
await ensureTagsForGroup({ userId, groupId, tags: ["groceries", "weekly"] });
const entry = await createEntry({
groupId,
userId,
entryType: "SPENDING",
amountDollars: 12.34,
occurredAt: "2026-02-02",
necessity: "NECESSARY",
purchaseType: "Groceries",
notes: "Test",
tags: ["groceries", "weekly"],
isRecurring: false,
intervalCount: 1
});
const list = await listEntries(groupId);
assert.equal(list.length, 1);
assert.equal(list[0].id, entry.id);
assert.deepEqual(list[0].tags.sort(), ["groceries", "weekly"]);
assert.equal(list[0].entryType, "SPENDING");
const updated = await updateEntry({
id: entry.id,
groupId,
userId,
entryType: "INCOME",
amountDollars: 15,
occurredAt: "2026-02-02",
necessity: "BOTH",
purchaseType: "Groceries",
notes: "Updated",
tags: ["groceries"],
isRecurring: true,
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
nextRunAt: "2026-02-02"
});
assert.ok(updated);
assert.equal(updated?.amountDollars, 15);
assert.equal(updated?.entryType, "INCOME");
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
await deleteEntry({ id: entry.id, groupId });
const listAfter = await listEntries(groupId);
assert.equal(listAfter.length, 0);
} finally {
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});

View File

@ -0,0 +1,81 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { deleteTagForGroup, ensureTagsForGroup, listGroupTags } from "../lib/server/tags";
import { getGroupSettings, setGroupSettings } from "../lib/server/group-settings";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("group tag permissions and management", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let adminId: number | null = null;
let memberId: number | null = null;
let groupId: number | null = null;
try {
const adminRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`tags_admin_${Date.now()}@example.com`, "hash"]
);
adminId = Number(adminRes.rows[0].id);
const memberRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`tags_member_${Date.now()}@example.com`, "hash"]
);
memberId = Number(memberRes.rows[0].id);
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Tags Group", uniqueInviteCode("T"), adminId]
);
groupId = Number(groupRes.rows[0].id);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')",
[groupId, adminId]
);
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')",
[groupId, memberId]
);
const settings = await getGroupSettings(groupId);
assert.equal(settings.allowMemberTagManage, false);
await assert.rejects(
() => ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food"] }),
{ message: "FORBIDDEN" }
);
await setGroupSettings({ userId: adminId, groupId, allowMemberTagManage: true });
const settingsAfter = await getGroupSettings(groupId);
assert.equal(settingsAfter.allowMemberTagManage, true);
await ensureTagsForGroup({ userId: memberId!, groupId: groupId!, tags: ["food", "dining"] });
const tags = await listGroupTags(groupId);
assert.deepEqual(tags, ["dining", "food"]);
await deleteTagForGroup({ userId: memberId!, groupId: groupId!, name: "food" });
const afterDelete = await listGroupTags(groupId);
assert.deepEqual(afterDelete, ["dining"]);
} finally {
await cleanupTestData(client, { userIds: [adminId, memberId], groupId });
client.release();
}
});

View File

@ -0,0 +1,67 @@
import type { Pool, PoolClient } from "pg";
type CleanupArgs = {
groupId?: number | null;
userIds?: Array<number | null | undefined>;
emails?: Array<string | null | undefined>;
};
export function uniqueInviteCode(prefix = "T"): string {
const raw = `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const cleaned = raw.toUpperCase().replace(/[^A-Z0-9]/g, "");
if (cleaned.length >= 8) return cleaned.slice(0, 8);
return cleaned.padEnd(8, "X");
}
export async function cleanupTestData(client: PoolClient, args: CleanupArgs) {
const groupId = args.groupId ?? null;
const userIds = (args.userIds || []).filter((id): id is number => Boolean(id));
const emails = (args.emails || []).filter((email): email is string => Boolean(email));
const safeQuery = async (text: string, params: Array<unknown>) => {
try {
await client.query(text, params);
} catch (error) {
if (typeof error === "object" && error && "code" in error && error.code === "42P01") return;
throw error;
}
};
if (groupId) {
await safeQuery(
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
[groupId]
);
await safeQuery("delete from bucket_tags where bucket_id in (select id from buckets where group_id=$1)", [groupId]);
await safeQuery("delete from buckets where group_id=$1", [groupId]);
await safeQuery("delete from entries where group_id=$1", [groupId]);
await safeQuery("delete from tags where group_id=$1", [groupId]);
await safeQuery("delete from group_audit_log where group_id=$1", [groupId]);
await safeQuery("delete from group_invite_links where group_id=$1", [groupId]);
await safeQuery("delete from group_join_requests where group_id=$1", [groupId]);
await safeQuery("delete from group_settings where group_id=$1", [groupId]);
await safeQuery("delete from group_members where group_id=$1", [groupId]);
await safeQuery("delete from groups where id=$1", [groupId]);
}
for (const userId of userIds) {
await safeQuery("delete from user_settings where user_id=$1", [userId]);
await safeQuery("delete from sessions where user_id=$1", [userId]);
await safeQuery("delete from users where id=$1", [userId]);
}
for (const email of emails) {
await safeQuery("delete from sessions where user_id in (select id from users where email=$1)", [email]);
await safeQuery("delete from users where email=$1", [email]);
}
}
export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
const client = await pool.connect();
try {
await cleanupTestData(client, args);
} finally {
client.release();
}
}

View File

@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth";
import { loginUser } from "@/lib/server/auth-service";
import { toErrorResponse } from "@/lib/server/errors";
export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const email = String(body?.email || "").trim().toLowerCase();
const password = String(body?.password || "");
const remember = Boolean(body?.remember ?? true);
if (!email || !password)
return NextResponse.json({ error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
let user;
let session;
try {
const result = await loginUser({ email, password, remember });
user = result.user;
session = result.session;
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
return NextResponse.json(body, { status });
}
const cookieStore = await cookies();
cookieStore.set(getSessionCookieName(), session.token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: Math.floor(session.ttlMs / 1000),
path: "/"
});
return NextResponse.json({ user });
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth";
import { logoutUser } from "@/lib/server/auth-service";
export async function POST() {
const cookieStore = await cookies();
const token = cookieStore.get(getSessionCookieName())?.value;
if (token)
await logoutUser(token);
cookieStore.set(getSessionCookieName(), "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 0,
path: "/"
});
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/server/session";
export async function GET() {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
return NextResponse.json({ user });
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName, getSessionTtlMs } from "@/lib/server/auth";
import { registerUser } from "@/lib/server/auth-service";
import { toErrorResponse } from "@/lib/server/errors";
export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const email = String(body?.email || "").trim().toLowerCase();
const password = String(body?.password || "");
const displayName = String(body?.displayName || "").trim();
if (!email || !email.includes("@"))
return NextResponse.json({ error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
if (password.length < 8)
return NextResponse.json({ error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
let user;
let session;
try {
const result = await registerUser({ email, password, displayName });
user = result.user;
session = result.session;
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
return NextResponse.json(body, { status });
}
const cookieStore = await cookies();
cookieStore.set(getSessionCookieName(), session.token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: Math.floor(getSessionTtlMs() / 1000),
path: "/"
});
return NextResponse.json({ user });
}

View File

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { deleteBucket, requireActiveGroup, updateBucket } from "@/lib/server/buckets";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim();
const description = String(body?.description || "").trim();
const iconKey = body?.iconKey ? String(body.iconKey) : null;
const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null;
const position = body?.position != null ? Number(body.position) : 0;
const tags = parseTags(body?.tags);
const necessity = String(body?.necessity || "BOTH").toUpperCase();
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
if (!name)
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
const bucket = await updateBucket({
id,
groupId,
userId: user.id,
name,
description: description || undefined,
iconKey,
budgetLimitDollars,
position,
tags,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, bucket });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
return NextResponse.json(body, { status });
}
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteBucket({ id, groupId });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/buckets/[id]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { createBucket, listBuckets, requireActiveGroup } from "@/lib/server/buckets";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const buckets = await listBuckets(groupId);
return NextResponse.json({ requestId, buckets });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim();
const description = String(body?.description || "").trim();
const iconKey = body?.iconKey ? String(body.iconKey) : null;
const budgetLimitDollars = body?.budgetLimitDollars != null ? Number(body.budgetLimitDollars) : null;
const position = body?.position != null ? Number(body.position) : 0;
const tags = parseTags(body?.tags);
const necessity = String(body?.necessity || "BOTH").toUpperCase();
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
if (budgetLimitDollars != null && (!Number.isFinite(budgetLimitDollars) || budgetLimitDollars < 0))
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365)
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
const bucket = await createBucket({
groupId,
userId: user.id,
name,
description: description || undefined,
iconKey,
budgetLimitDollars,
position,
tags,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
return NextResponse.json({ requestId, bucket });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, updateEntry, deleteEntry } from "@/lib/server/entries";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
const body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0);
const occurredAt = String(body?.occurredAt || "");
const necessity = String(body?.necessity || "");
const tags = parseTags(body?.tags);
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const isRecurring = Boolean(body?.isRecurring);
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await updateEntry({
id,
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
occurredAt,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
isRecurring,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId
});
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
return NextResponse.json(body, { status });
}
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteEntry({ id, groupId });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/entries/[id]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { createEntry, listEntries, requireActiveGroup } from "@/lib/server/entries";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const entries = await listEntries(groupId);
return NextResponse.json({ requestId, entries });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0);
const occurredAt = String(body?.occurredAt || "");
const necessity = String(body?.necessity || "");
const tags = parseTags(body?.tags);
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const isRecurring = Boolean(body?.isRecurring);
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await createEntry({
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
occurredAt,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
isRecurring,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId
});
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { getActiveGroupId, listGroups, setActiveGroupForUser } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groups = await listGroups(user.id);
const activeGroupId = await getActiveGroupId(user.id);
const active = groups.find(group => Number(group.id) === activeGroupId) || null;
return NextResponse.json({ requestId, active });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const body = await req.json().catch(() => null);
const groupId = Number(body?.groupId || 0);
if (!groupId) return NextResponse.json({ requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
await setActiveGroupForUser(user.id, groupId);
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/active", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { listGroupAudit } from "@/lib/server/group-audit";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const events = await listGroupAudit({ userId: user.id, groupId });
return NextResponse.json({ requestId, events });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/audit", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST() {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/delete", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { deleteInviteLink } from "@/lib/server/group-invites";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0);
if (!linkId)
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
await deleteInviteLink({ linkId, userId: user.id, groupId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/delete", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { reviveInviteLink } from "@/lib/server/group-invites";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!linkId)
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
if (!ttlDays)
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
await reviveInviteLink({ userId: user.id, groupId, linkId, expiresAt, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revive", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { revokeInviteLink } from "@/lib/server/group-invites";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0);
if (!linkId)
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
await revokeInviteLink({ userId: user.id, groupId, linkId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites/revoke", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { createInviteLink, listInviteLinks } from "@/lib/server/group-invites";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const links = await listInviteLinks({ userId: user.id, groupId });
return NextResponse.json({ requestId, links });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const policy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.policy))
? (String(body?.policy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")
: "NOT_ACCEPTING";
const singleUse = Boolean(body?.singleUse);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!ttlDays)
return NextResponse.json({ requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
const link = await createInviteLink({ userId: user.id, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent });
return NextResponse.json({ requestId, link });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { joinGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const body = await req.json().catch(() => null);
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
if (!inviteCode)
return NextResponse.json({ requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
const group = await joinGroup(user.id, inviteCode);
return NextResponse.json({ requestId, group });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { approveJoinRequest } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
const joinRequestId = Number(body?.requestId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await approveJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent, requestRowId: joinRequestId || undefined });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/approve", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { demoteAdmin } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await demoteAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/demote", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { denyJoinRequest } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await denyJoinRequest({ actorUserId: user.id, groupId, userId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/deny", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { kickMember } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await kickMember({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/kick", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { leaveGroup } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST() {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/leave", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { promoteToAdmin } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await promoteToAdmin({ actorUserId: user.id, groupId, targetUserId: userId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/promote", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { listGroupMembers, listJoinRequests } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const members = await listGroupMembers(groupId);
const requests = await listJoinRequests({ userId: user.id, groupId });
return NextResponse.json({ requestId, members, requests, currentUserId: user.id });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/members", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { transferOwnership } from "@/lib/server/group-members";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
await transferOwnership({ actorUserId: user.id, groupId, newOwnerUserId: userId, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/members/transfer-owner", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim();
if (!name)
return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
await renameGroup({ userId: user.id, groupId, name, requestId, ip, userAgent });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/rename", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { createGroup, listGroups } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groups = await listGroups(user.id);
return NextResponse.json({ requestId, groups });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim();
if (!name) return NextResponse.json({ requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
const group = await createGroup(user.id, name);
return NextResponse.json({ requestId, group });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { getGroupSettings, setGroupSettings } from "@/lib/server/group-settings";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const allowMemberTagManage = Boolean(body?.allowMemberTagManage);
const joinPolicy = ["NOT_ACCEPTING", "AUTO_ACCEPT", "APPROVAL_REQUIRED"].includes(String(body?.joinPolicy))
? (String(body?.joinPolicy) as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")
: "NOT_ACCEPTING";
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { getSessionUser, requireSessionUser } from "@/lib/server/session";
import { apiError, toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
import { acceptInviteLink, getInviteLinkSummaryByToken, getInviteViewerStatus } from "@/lib/server/group-invites";
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
const { requestId } = await getRequestMeta();
try {
const { token } = await context.params;
const normalized = String(token || "").trim();
if (!normalized) apiError("INVITE_NOT_FOUND");
const link = await getInviteLinkSummaryByToken(normalized);
if (!link) apiError("INVITE_NOT_FOUND", { tokenLast4: normalized.slice(-4) });
const user = await getSessionUser();
if (user) {
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
if (viewerStatus)
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
}
return NextResponse.json({ requestId, link });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
const user = await requireSessionUser();
const { token } = await context.params;
const normalized = String(token || "").trim();
if (!normalized) apiError("INVITE_NOT_FOUND");
const result = await acceptInviteLink({ userId: user.id, token: normalized, requestId, ip, userAgent });
return NextResponse.json({ requestId, result });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/invite-links/[token]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { deleteRecurringEntry, requireActiveGroup, updateRecurringEntry } from "@/lib/server/recurring-entries";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
const body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0);
const occurredAt = String(body?.occurredAt || "");
const necessity = String(body?.necessity || "");
const tags = parseTags(body?.tags);
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await updateRecurringEntry({
id,
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
occurredAt,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
isRecurring: true,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId
});
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
return NextResponse.json(body, { status });
}
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteRecurringEntry({ id, groupId });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/recurring-entries/[id]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
function parseTags(value: unknown) {
if (Array.isArray(value)) return value.map(tag => String(tag));
if (typeof value === "string") return value.split(",");
return [] as string[];
}
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const entries = await listRecurringEntries(groupId);
return NextResponse.json({ requestId, entries });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0);
const occurredAt = String(body?.occurredAt || "");
const necessity = String(body?.necessity || "");
const tags = parseTags(body?.tags);
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await createRecurringEntry({
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
occurredAt,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
isRecurring: true,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId
});
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/entries";
import { deleteTagForGroup } from "@/lib/server/tags";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function DELETE(_: Request, { params }: { params: Promise<{ name: string }> }) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const { name } = await params;
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
return NextResponse.json({ requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/tags/[name]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/entries";
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const tags = await listGroupTags(groupId);
return NextResponse.json({ requestId, tags });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
return NextResponse.json(body, { status });
}
}
export async function POST(req: Request) {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const body = await req.json().catch(() => null);
const tags = Array.isArray(body?.tags) ? body.tags.map((tag: unknown) => String(tag)) : [];
await ensureTagsForGroup({ userId: user.id, groupId, tags });
const list = await listGroupTags(groupId);
return NextResponse.json({ requestId, tags: list });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
return NextResponse.json(body, { status });
}
}

BIN
apps/web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

179
apps/web/app/globals.css Normal file
View File

@ -0,0 +1,179 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--color-bg: #0b0f17;
--color-surface: #111827;
--color-surface-muted: #0f172a;
--color-input: #0b1220;
--color-border: rgba(148, 163, 184, 0.18);
--color-divider: rgba(30, 144, 255, 0.25);
--color-accent: #1e90ff;
--color-accent-strong: #4aa3ff;
--color-accent-soft: rgba(30, 144, 255, 0.12);
--color-accent-border: rgba(30, 144, 255, 0.55);
--color-accent-border-weak: rgba(30, 144, 255, 0.32);
--color-accent-focus: rgba(30, 144, 255, 0.45);
--color-text: #e5e7eb;
--color-muted: #a1acc1;
--color-muted-2: #7b8aa3;
}
.theme-light {
color-scheme: light;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-surface-muted: #f1f5f9;
--color-input: #ffffff;
--color-border: rgba(30, 144, 255, 0.25);
--color-divider: rgba(30, 144, 255, 0.2);
--color-accent: #1e90ff;
--color-accent-strong: #0f7be6;
--color-accent-soft: rgba(30, 144, 255, 0.12);
--color-accent-border: rgba(30, 144, 255, 0.55);
--color-accent-border-weak: rgba(30, 144, 255, 0.32);
--color-accent-focus: rgba(30, 144, 255, 0.35);
--color-text: #0f172a;
--color-muted: #64748b;
--color-muted-2: #475569;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
}
@layer components {
.bg-app {
background-color: var(--color-bg);
}
.bg-surface {
background-color: var(--color-surface);
}
.bg-panel {
background-color: var(--color-surface-muted);
}
.bg-accent {
background-color: var(--color-accent);
}
.bg-accent-soft {
background-color: var(--color-accent-soft);
}
.bg-input {
background-color: var(--color-input);
}
.text-muted {
color: var(--color-muted);
}
.text-soft {
color: var(--color-muted-2);
}
.border-default {
border-color: var(--color-border);
}
.border-accent {
border-color: var(--color-accent-border);
}
.border-accent-weak {
border-color: var(--color-accent-border-weak);
}
.border-accent-strong {
border-color: var(--color-accent);
}
.divider {
background-color: var(--color-divider);
}
.panel {
background-color: var(--color-surface-muted);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}
.panel-accent {
border-color: var(--color-accent-border-weak);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-accent-border-weak);
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
}
.input-base {
background-color: var(--color-input);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
color: var(--color-text);
}
.date-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.date-input::-webkit-calendar-picker-indicator {
opacity: 0;
}
.input-base:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent-focus);
outline: none;
}
.btn-accent {
background-color: var(--color-accent);
color: #fff;
}
.btn-accent:hover {
background-color: var(--color-accent-strong);
}
.btn-outline-accent {
border: 1px solid var(--color-accent-border);
color: var(--color-text);
background-color: transparent;
}
.btn-outline-accent:hover {
border-color: var(--color-accent);
}
/* Hide the calendar icon on Chromium/Safari */
input.no-date-icon::-webkit-calendar-picker-indicator {
opacity: 0;
display: none;
/* usually fine */
-webkit-appearance: none;
}
/* Optional: hide the little spin buttons some browsers show */
input.no-date-icon::-webkit-inner-spin-button {
display: none;
}
}

View File

@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import GroupSettingsContent from "@/components/group-settings-content";
export default async function GroupSettingsPage({ params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser();
if (!user) redirect("/login");
const { id } = await params;
const groupId = Number(id || 0);
if (!groupId) redirect("/");
return <GroupSettingsContent groupId={groupId} />;
}

View File

@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import GroupSettingsContent from "@/components/group-settings-content";
export default async function GroupSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
try {
const groupId = await requireActiveGroup(user.id);
return <GroupSettingsContent groupId={groupId} />;
} catch {
redirect("/");
}
}

View File

@ -0,0 +1,282 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthContext } from "@/hooks/auth-context";
import useInviteLink from "@/hooks/use-invite-link";
export default function InvitePage() {
const params = useParams();
const router = useRouter();
const { checkSession } = useAuthContext();
const token = useMemo(() => {
const raw = params?.token;
if (typeof raw === "string") return raw;
if (Array.isArray(raw)) return raw[0] || "";
return "";
}, [params]);
const { link, loading, accepting, error, result, accept } = useInviteLink(token || null);
const [checkingSession, setCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [redirectOpen, setRedirectOpen] = useState(false);
const [redirectMessage, setRedirectMessage] = useState("");
const [secondsLeft, setSecondsLeft] = useState(3);
const viewerStatus = link?.viewerStatus || "";
const isAlreadyMember = viewerStatus === "ALREADY_MEMBER";
const isPendingViewer = viewerStatus === "PENDING";
const groupPolicy = link?.groupJoinPolicy || link?.policy || "NOT_ACCEPTING";
const isDisabled = groupPolicy === "NOT_ACCEPTING";
const isManual = groupPolicy === "APPROVAL_REQUIRED";
const isRevoked = Boolean(link?.revokedAt);
const isExpired = link?.expiresAt ? new Date(link.expiresAt).getTime() < Date.now() : false;
const isUsed = Boolean(link?.singleUse && link?.usedAt);
const joinBlockedMessage = isAlreadyMember
? "You are already a member of this group."
: isPendingViewer
? "You currently have a pending join request."
: isDisabled
? "Invites are disabled for this group."
: isRevoked
? "This invite link has been revoked."
: isExpired
? "This invite link has expired."
: isUsed
? "This invite link has already been used."
: "";
useEffect(() => {
let active = true;
async function runCheck() {
const user = await checkSession();
if (!active) return;
setHasSession(Boolean(user));
setCheckingSession(false);
}
runCheck();
return () => {
active = false;
};
}, [checkSession]);
useEffect(() => {
if (!link || loading) return;
if (isAlreadyMember) {
setRedirectMessage("You are already a member of this group.");
setRedirectOpen(true);
return;
}
if (isPendingViewer) {
setRedirectMessage("Your join request is pending approval.");
setRedirectOpen(true);
return;
}
if (isRevoked) {
setRedirectMessage("This invite link has been revoked.");
setRedirectOpen(true);
} else if (isExpired) {
setRedirectMessage("This invite link has expired.");
setRedirectOpen(true);
} else if (isUsed) {
setRedirectMessage("This invite link has already been used.");
setRedirectOpen(true);
}
}, [link, loading, isAlreadyMember, isPendingViewer, isRevoked, isExpired, isUsed]);
useEffect(() => {
if (!error) return;
const lower = error.toLowerCase();
if (lower.includes("revoked")) {
setRedirectMessage("This invite link has been revoked.");
setRedirectOpen(true);
return;
}
if (lower.includes("expired")) {
setRedirectMessage("This invite link has expired.");
setRedirectOpen(true);
return;
}
if (lower.includes("used")) {
setRedirectMessage("This invite link has already been used.");
setRedirectOpen(true);
return;
}
if (lower.includes("not found") || lower.includes("invalid invite")) {
setRedirectMessage("This invite link no longer exists.");
setRedirectOpen(true);
}
}, [error]);
useEffect(() => {
if (!result) return;
if (result.status === "JOINED") setRedirectMessage("You have joined the group.");
if (result.status === "PENDING") setRedirectMessage("Your join request is pending approval.");
if (result.status === "ALREADY_MEMBER") setRedirectMessage("You are already a member of this group.");
setRedirectOpen(true);
}, [result]);
useEffect(() => {
if (!redirectOpen) return;
setSecondsLeft(3);
const timer = window.setInterval(() => {
setSecondsLeft(prev => {
if (prev <= 1) {
window.clearInterval(timer);
router.push("/");
return 0;
}
return prev - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [redirectOpen, router]);
const actionLabel = result?.status === "JOINED"
? "Joined"
: result?.status === "PENDING"
? "Request sent"
: result?.status === "ALREADY_MEMBER"
? "Already a member"
: "Join group";
return (
<div className="mx-auto max-w-md space-y-4">
<div className="flex items-center gap-2">
<img
src="/icons/navbar-settings.png"
alt=""
className="h-7 w-7 rounded-lg object-cover"
/>
<h1 className="text-2xl font-semibold">Group invite</h1>
</div>
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Invite details</div>
</div>
{loading ? (
<div className="text-sm text-muted">Loading invite</div>
) : error ? (
<div className="text-sm text-red-400">{error}</div>
) : link ? (
<div className="space-y-2 text-sm text-muted">
<div>
<div className="text-xs text-soft">Group</div>
<div className="text-base font-semibold text-[color:var(--color-text)]">{link.groupName}</div>
</div>
{isAlreadyMember ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
You are already a member of this group.
</div>
) : isPendingViewer ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
Your join request is pending approval.
</div>
) : isDisabled ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
Invites are disabled for this group.
</div>
) : isRevoked ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has been revoked.
</div>
) : isExpired ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has expired.
</div>
) : isUsed ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has already been used.
</div>
) : isManual ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
Requests to join require approval.
</div>
) : null}
</div>
) : (
<div className="text-sm text-muted">Invite not found.</div>
)}
</div>
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Join this group</div>
</div>
{checkingSession ? (
<div className="text-sm text-muted">Checking session</div>
) : !hasSession ? (
<div className="space-y-3">
<div className="text-sm text-muted">Sign in to accept this invite.</div>
<div className="flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/login")}
>
Sign in
</button>
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/register")}
>
Create account
</button>
</div>
</div>
) : (
<div className="space-y-3">
{result ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm text-muted">
{result.status === "JOINED" && "You joined the group."}
{result.status === "ALREADY_MEMBER" && "You are already in this group."}
{result.status === "PENDING" && "Join request sent for approval."}
</div>
) : joinBlockedMessage ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{joinBlockedMessage}
</div>
) : null}
{!joinBlockedMessage ? (
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
disabled={!link || Boolean(result) || accepting}
onClick={accept}
>
{accepting ? "Joining…" : actionLabel}
</button>
) : null}
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/")}
>
Go to dashboard
</button>
</div>
)}
</div>
{redirectOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 !mt-0">
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5">
<div className="text-lg font-semibold">Redirecting</div>
<p className="mt-2 text-sm text-muted">{redirectMessage}</p>
<p className="mt-2 text-xs text-soft">Taking you to entries in {secondsLeft}s.</p>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/")}
>
Go now
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

21
apps/web/app/layout.tsx Normal file
View File

@ -0,0 +1,21 @@
import "./globals.css";
import type { Metadata } from "next";
import AppProviders from "@/components/app-providers";
import AppFrame from "@/components/app-frame";
export const metadata: Metadata = {
title: "Fiddy",
description: "Budgeting & collaboration"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AppProviders>
<AppFrame>{children}</AppFrame>
</AppProviders>
</body>
</html>
);
}

141
apps/web/app/login/page.tsx Normal file
View File

@ -0,0 +1,141 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useAuthContext } from "@/hooks/auth-context";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [error, setError] = useState("");
const { checkSession, login, loading } = useAuthContext();
const [checking, setChecking] = useState(true);
useEffect(() => {
let active = true;
async function runCheckSession() {
try {
const user = await checkSession();
if (!active) return;
if (user) {
router.replace("/");
return;
}
} finally {
if (active) setChecking(false);
}
}
runCheckSession();
return () => {
active = false;
};
}, [router, checkSession]);
function validate() {
if (!email.trim()) return "Email is required";
if (!email.includes("@")) return "Enter a valid email";
if (!password) return "Password is required";
return "";
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setError("");
const result = await login({ email, password, remember });
if (!result.ok) {
setError(result.error || "Login failed");
return;
}
router.replace("/");
}
return (
<div className="mx-auto max-w-sm space-y-4">
<div className="flex items-center gap-2">
<img
src="/icons/navbar-settings.png"
alt=""
className="h-7 w-7 rounded-lg object-cover"
/>
<h1 className="text-2xl font-semibold">Login</h1>
</div>
{checking ? (
<div className="panel panel-accent p-4 text-muted">
Checking session...
</div>
) : null}
<form
onSubmit={onSubmit}
className="space-y-3 panel panel-accent p-4"
>
<label className="block text-sm text-muted">
Email
<input
type="email"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
required
disabled={loading || checking}
/>
</label>
<label className="block text-sm text-muted">
Password
<input
type="password"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
disabled={loading || checking}
/>
</label>
<div className="flex items-center justify-between text-sm text-muted">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-accent-weak bg-input"
checked={remember}
onChange={e => setRemember(e.target.checked)}
disabled={loading || checking}
/>
Remember me
</label>
<a
href="#"
onClick={e => e.preventDefault()}
className="text-[color:var(--color-accent)]"
>
Forgot password?
</a>
</div>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
<button
type="submit"
disabled={loading || checking}
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
>
{loading ? "Signing in..." : "Sign in"}
</button>
<div className="text-center text-sm text-muted">
No account?{" "}
<a href="/register" className="text-[color:var(--color-accent)]">
Register
</a>
</div>
</form>
</div>
);
}

10
apps/web/app/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import DashboardContent from "@/components/dashboard-content";
export default async function Page() {
const user = await getSessionUser();
if (!user) redirect("/login");
return <DashboardContent />;
}

View File

@ -0,0 +1,90 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useAuthContext } from "@/hooks/auth-context";
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const { register, loading } = useAuthContext();
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
const result = await register({ email, password, displayName });
if (!result.ok) {
setError(result.error || "Registration failed");
return;
}
router.replace("/");
}
return (
<div className="mx-auto max-w-sm space-y-4">
<div className="flex items-center gap-2">
<img
src="/icons/navbar-settings.png"
alt=""
className="h-7 w-7 rounded-lg object-cover"
/>
<h1 className="text-2xl font-semibold">Register</h1>
</div>
<form
onSubmit={onSubmit}
className="space-y-3 panel panel-accent p-4"
>
<label className="block text-sm text-muted">
Email
<input
type="email"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
required
/>
</label>
<label className="block text-sm text-muted">
Display name
<input
type="text"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
autoComplete="name"
/>
</label>
<label className="block text-sm text-muted">
Password
<input
type="password"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</label>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
>
{loading ? "Creating account..." : "Create account"}
</button>
<div className="text-center text-sm text-muted">
Already have an account?{" "}
<a href="/login" className="text-[color:var(--color-accent)]">
Login
</a>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import type React from "react";
import { usePathname } from "next/navigation";
import Navbar from "@/components/navbar";
const NO_NAVBAR_PATHS = new Set(["/login", "/register"]);
const NO_NAVBAR_PREFIXES = ["/invite"];
type AppFrameProps = {
children: React.ReactNode;
};
export default function AppFrame({ children }: AppFrameProps) {
const pathname = usePathname();
const hideNavbar = pathname
? NO_NAVBAR_PATHS.has(pathname) || NO_NAVBAR_PREFIXES.some(prefix => pathname.startsWith(prefix))
: false;
return (
<>
{hideNavbar ? null : <Navbar />}
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import type React from "react";
import { AuthProvider } from "@/hooks/auth-context";
import { GroupsProvider } from "@/hooks/groups-context";
import { NotificationsProvider } from "@/hooks/notifications-context";
type AppProvidersProps = {
children: React.ReactNode;
};
export default function AppProviders({ children }: AppProvidersProps) {
return (
<NotificationsProvider>
<AuthProvider>
<GroupsProvider>{children}</GroupsProvider>
</AuthProvider>
</NotificationsProvider>
);
}

View File

@ -0,0 +1,151 @@
import { Bucket } from "@/lib/server/buckets";
import React from "react";
type BucketCardProps = {
bucket: Bucket;
icon?: string | null;
isExpanded: boolean;
toggleExpanded: (bucketId: number) => void;
isMenuOpen: boolean;
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
setConfirmDeleteId: (bucketId: number) => void;
openEdit: (bucketId: number) => void;
limit: number;
usageLabel: string;
renderUsageBar: (bucket: Bucket) => React.ReactNode;
};
export function BucketCard({
bucket,
icon,
isExpanded,
toggleExpanded,
isMenuOpen,
setMenuOpenId,
setConfirmDeleteId,
openEdit,
limit,
usageLabel,
renderUsageBar,
}: BucketCardProps) {
return (
<div
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
onClick={() => toggleExpanded(bucket.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
{icon || "🚫"}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{bucket.name}</div>
{bucket.description ? (
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
{bucket.description}
</div>
) : null}
</div>
</div>
<div className="relative" data-bucket-menu>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={(event) => {
event.stopPropagation();
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
}}
aria-label="Bucket actions"
data-bucket-menu-button
>
</button>
{isMenuOpen ? (
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
<button
type="button"
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
onClick={(event) => {
event.stopPropagation();
openEdit(bucket.id);
}}
>
Edit
</button>
<button
type="button"
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
onClick={(event) => {
event.stopPropagation();
setConfirmDeleteId(bucket.id);
}}
>
Delete
</button>
</div>
) : null}
</div>
</div>
{limit > 0 ? (
<>
{renderUsageBar(bucket)}
{isExpanded ? (
<div className="mt-2 space-y-2 text-xs text-soft">
<div>{usageLabel}</div>
<div className="flex flex-wrap gap-2">
{bucket.tags?.length ? (
bucket.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
>
#{tag}
</span>
))
) : (
<span className="text-[11px] text-soft">No tags</span>
)}
</div>
</div>
) : null}
</>
) : isExpanded ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
{bucket.tags?.length ? (
bucket.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
>
#{tag}
</span>
))
) : (
<span className="text-[11px] text-soft">No tags</span>
)}
</div>
) : null}
</div>
);
}
export default React.memo(BucketCard, (prev, next) => (
prev.bucket === next.bucket
&& prev.icon === next.icon
&& prev.isExpanded === next.isExpanded
&& prev.isMenuOpen === next.isMenuOpen
&& prev.limit === next.limit
&& prev.usageLabel === next.usageLabel
));

View File

@ -0,0 +1,230 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useGroupsContext } from "@/hooks/groups-context";
import useBuckets from "@/hooks/use-buckets";
import useTags from "@/hooks/use-tags";
import NewBucketModal from "@/components/new-bucket-modal";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import BucketCard from "./bucket-card";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
export default function BucketsPanel() {
const { activeGroupId } = useGroupsContext();
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
const { mutationVersion } = useEntryMutation();
const { tags: tagSuggestions } = useTags(activeGroupId);
const [modalOpen, setModalOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<number[]>([]);
const [form, setForm] = useState({
name: "",
description: "",
iconKey: "none",
budgetLimitDollars: "",
tags: [] as string[],
necessity: "BOTH",
windowDays: "30"
});
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!menuOpenId) return;
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
setMenuOpenId(null);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpenId]);
useEffect(() => {
if (!activeGroupId) return;
if (mutationVersion === 0) return;
reload();
}, [mutationVersion, activeGroupId, reload]);
function resetForm() {
setForm({ name: "", description: "", iconKey: "none", budgetLimitDollars: "", tags: [], necessity: "BOTH", windowDays: "30" });
setEditId(null);
}
function openCreate() {
resetForm();
setModalOpen(true);
}
function openEdit(bucketId: number) {
const bucket = buckets.find(item => item.id === bucketId);
if (!bucket) return;
setEditId(bucketId);
setForm({
name: bucket.name,
description: bucket.description || "",
iconKey: bucket.iconKey || "none",
budgetLimitDollars: bucket.budgetLimitDollars != null ? String(bucket.budgetLimitDollars) : "",
tags: bucket.tags || [],
necessity: bucket.necessity,
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
});
setModalOpen(true);
setMenuOpenId(null);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const budget = form.budgetLimitDollars ? Number(form.budgetLimitDollars) : null;
const windowDays = form.windowDays ? Number(form.windowDays) : 30;
if (!form.name.trim()) return;
const iconKey = form.iconKey && form.iconKey !== "none" ? form.iconKey : null;
if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) return;
if (editId) {
const ok = await updateBucket({
id: editId,
name: form.name.trim(),
description: form.description.trim() || undefined,
iconKey,
budgetLimitDollars: budget,
tags: form.tags,
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) setModalOpen(false);
} else {
const ok = await createBucket({
name: form.name.trim(),
description: form.description.trim() || undefined,
iconKey,
budgetLimitDollars: budget,
tags: form.tags,
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) setModalOpen(false);
}
}
function toggleExpanded(bucketId: number) {
setExpandedIds(prev => prev.includes(bucketId)
? prev.filter(id => id !== bucketId)
: [...prev, bucketId]);
}
function budgetUsage(bucket: typeof buckets[number]) {
const limit = bucket.budgetLimitDollars || 0;
const spent = bucket.totalUsage || 0;
const pct = limit > 0 ? (spent / limit) * 100 : 0;
return { limit, spent, pct };
}
function renderUsageBar(bucket: typeof buckets[number]) {
const { limit, spent, pct } = budgetUsage(bucket);
if (!limit) return null;
const clamped = Math.max(0, pct);
const overage = Math.max(0, clamped - 100);
const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400";
const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400";
return (
<div className="mt-3">
<div className="h-2 w-full rounded-full bg-surface">
<div
className={`h-2 rounded-full ${tone}`}
style={{ width: `${Math.min(100, clamped)}%` }}
/>
</div>
</div>
);
}
return (
<>
<div className="panel panel-accent p-4">
<div className="card-header">
<h2 className="card-title text-lg">Buckets</h2>
<button
type="button"
onClick={openCreate}
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
disabled={!activeGroupId}
aria-label="Add bucket"
>
+
</button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view buckets.</div>
) : loading ? (
<div className="space-y-2">
{[0, 1].map(row => (
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
<div className="animate-pulse space-y-2">
<div className="h-4 w-28 rounded bg-surface" />
<div className="h-3 w-40 rounded bg-surface" />
</div>
</div>
))}
</div>
) : orderedBuckets.length ? (
orderedBuckets.map(bucket => {
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
const { limit, spent } = budgetUsage(bucket);
const isExpanded = expandedIds.includes(bucket.id);
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
return <BucketCard
key={bucket.id}
bucket={bucket}
icon={icon}
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
isMenuOpen={menuOpenId === bucket.id}
setMenuOpenId={setMenuOpenId}
setConfirmDeleteId={setConfirmDeleteId}
openEdit={openEdit}
limit={limit}
usageLabel={usageLabel}
renderUsageBar={renderUsageBar}
/>
})
) : (
<div className="text-sm text-muted">No buckets yet.</div>
)}
</div>
</div>
<NewBucketModal
isOpen={modalOpen}
title={editId ? "Edit bucket" : "New bucket"}
form={form}
error={error}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmit}
onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmDeleteId)}
title="Delete bucket"
description="This will permanently remove the bucket."
confirmLabel="Delete bucket"
onClose={() => setConfirmDeleteId(null)}
onConfirm={async () => {
if (!confirmDeleteId) return;
const ok = await deleteBucket(confirmDeleteId);
if (ok) setConfirmDeleteId(null);
}}
/>
</>
);
}

View File

@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useState } from "react";
type ConfirmSlideModalProps = {
isOpen: boolean;
title: string;
description?: string;
confirmLabel?: string;
onClose: () => void;
onConfirm: () => void;
};
export default function ConfirmSlideModal({
isOpen,
title,
description,
confirmLabel = "Confirm",
onClose,
onConfirm
}: ConfirmSlideModalProps) {
const trackRef = useRef<HTMLDivElement | null>(null);
const [dragX, setDragX] = useState(0);
const [dragging, setDragging] = useState(false);
const handleSize = 44;
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.preventDefault();
setDragging(true);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
if (!dragging) return;
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const next = Math.min(Math.max(0, event.clientX - rect.left - handleSize / 2), rect.width - handleSize);
setDragX(next);
}
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
if (!dragging) return;
setDragging(false);
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
const track = trackRef.current;
if (!track) return;
const threshold = (track.clientWidth - handleSize) * 0.8;
if (dragX >= threshold) {
setDragX(0);
onConfirm();
} else {
setDragX(0);
}
}
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5" onClick={event => event.stopPropagation()}>
<div className="text-lg font-semibold">{title}</div>
{description ? <p className="mt-2 text-sm text-muted">{description}</p> : null}
<div className="mt-4">
<div className="text-xs text-soft">Slide to confirm</div>
<div
ref={trackRef}
className="mt-2 h-11 rounded-full border border-accent-weak bg-surface relative overflow-hidden touch-none select-none"
>
<div
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
style={{ width: dragX + handleSize }}
/>
<button
type="button"
className="absolute top-0 left-0 h-11 w-11 rounded-full border border-accent bg-panel text-xl font-semibold text-[color:var(--color-text)] touch-none select-none leading-none"
style={{ transform: `translateX(${dragX}px)` }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
aria-label="Slide to confirm"
>
</button>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-soft">{confirmLabel}</div>
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm" onClick={onClose}>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { useGroupsContext } from "@/hooks/groups-context";
import EntriesPanel from "@/components/entries-panel";
import BucketsPanel from "@/components/buckets-panel";
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
export default function DashboardContent() {
const { groups, activeGroupId, loading } = useGroupsContext();
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? null;
if (loading) {
return (
<div className="space-y-4">
<div className="panel panel-accent p-4">
<div className="space-y-3 animate-pulse">
<div className="h-4 w-40 rounded bg-surface" />
<div className="h-3 w-64 rounded bg-surface" />
<div className="h-3 w-52 rounded bg-surface" />
</div>
</div>
</div>
);
}
if (!activeGroup) {
return (
<div className="space-y-4">
<div className="panel panel-accent p-4 text-sm text-muted">
Create or join a group to add entries.
</div>
</div>
);
}
return (
<EntryMutationProvider>
<div className="space-y-4">
<BucketsPanel />
<EntriesPanel />
</div>
</EntryMutationProvider>
);
}

View File

@ -0,0 +1,785 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useEntries from "@/hooks/use-entries";
import { useGroupsContext } from "@/hooks/groups-context";
import NewEntryModal from "@/components/new-entry-modal";
import EntryDetailsModal from "@/components/entry-details-modal";
import { useNotificationsContext } from "@/hooks/notifications-context";
import useTags from "@/hooks/use-tags";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import useGroupSettings from "@/hooks/use-group-settings";
import TagInput from "@/components/tag-input";
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
export default function EntriesPanel() {
const today = new Date().toISOString().slice(0, 10);
const { groups, activeGroupId } = useGroupsContext();
const router = useRouter();
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
const { notify } = useNotificationsContext();
const { tags: tagSuggestions } = useTags(activeGroupId);
const { settings } = useGroupSettings(activeGroupId);
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || settings.allowMemberTagManage));
const emptyTagActionLabel = canManageTags
? "No Tags Assigned Yet - Click To Assign Tags"
: "No Tags Assigned Yet - Contact Your Group Admin";
const [form, setForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME",
isRecurring: false,
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
intervalCount: 1,
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
endCount: "",
endDate: ""
});
const [isModalOpen, setIsModalOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [detailsForm, setDetailsForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME",
isRecurring: false,
frequency: "MONTHLY" as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY",
intervalCount: 1,
endCondition: "NEVER" as "NEVER" | "AFTER_COUNT" | "BY_DATE",
endCount: "",
endDate: ""
});
const [detailsOriginal, setDetailsOriginal] = useState<typeof detailsForm | null>(null);
const [removedTags, setRemovedTags] = useState<string[]>([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
const emptyFilters = {
amountMin: "",
amountMax: "",
dateFrom: "",
dateTo: "",
necessity: "ANY",
notesQuery: "",
tags: [] as string[],
tagsMode: "ANY" as "ANY" | "ALL"
};
const [filters, setFilters] = useState(emptyFilters);
const amountInputRef = useRef<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(null);
const pendingDiscardRef = useRef<
| { type: "close" }
| { type: "prev" }
| { type: "next" }
| { type: "open"; entry: typeof entries[number]; index: number }
| null
>(null);
const filteredEntries = useMemo(() => {
if (!entries.length) return entries;
const min = filters.amountMin ? Number(filters.amountMin) : null;
const max = filters.amountMax ? Number(filters.amountMax) : null;
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
const query = filters.notesQuery.trim().toLowerCase();
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
return entries.filter(entry => {
if (min != null && entry.amountDollars < min) return false;
if (max != null && entry.amountDollars > max) return false;
if (from != null) {
const time = new Date(entry.occurredAt).getTime();
if (!Number.isNaN(from) && time < from) return false;
}
if (to != null) {
const time = new Date(entry.occurredAt).getTime();
if (!Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
}
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
if (query) {
const notes = (entry.notes || "").toLowerCase();
if (!notes.includes(query)) return false;
}
if (tagsFilter.length) {
const entryTags = (entry.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") {
if (!tagsFilter.every(tag => entryTags.includes(tag))) return false;
} else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false;
}
return true;
});
}, [entries, filters]);
const visibleEntries = useMemo(() => filteredEntries.filter(entry => entry.isRecurring === (entryTab === "RECURRING")), [filteredEntries, entryTab]);
const totalEntries = visibleEntries.length;
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.amountMin) count += 1;
if (filters.amountMax) count += 1;
if (filters.dateFrom) count += 1;
if (filters.dateTo) count += 1;
if (filters.necessity !== "ANY") count += 1;
if (filters.notesQuery.trim()) count += 1;
if (filters.tags.length) count += 1;
return count;
}, [filters]);
function handleEmptyTagAction() {
if (!activeGroupId || !canManageTags) return;
router.push("/groups/settings");
}
useEffect(() => {
if (tagsInputRef.current)
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
}, [form.tags.length]);
useEffect(() => {
if (!filterOpen && !discardOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (discardOpen) handleCancelDiscard();
if (filterOpen) setFilterOpen(false);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [discardOpen, filterOpen]);
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (tagsInputRef.current)
tagsInputRef.current.setCustomValidity(form.tags.length ? "" : "Please fill out this field");
if (!e.currentTarget.reportValidity()) {
if (!form.amountDollars) amountInputRef.current?.focus();
else if (!form.tags.length) tagsInputRef.current?.focus();
return;
}
const amountDollars = Number(form.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
return;
}
if (!form.occurredAt) {
return;
}
if (!form.tags.length) {
return;
}
const purchaseType = form.tags.join(", ") || "General";
const nextRunAt = form.isRecurring ? form.occurredAt : null;
const createdEntry = await createEntry({
entryType: form.entryType,
amountDollars,
occurredAt: form.occurredAt,
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: form.notes.trim() || undefined,
tags: form.tags,
isRecurring: form.isRecurring,
frequency: form.isRecurring ? form.frequency : null,
intervalCount: form.intervalCount,
endCondition: form.isRecurring ? form.endCondition : null,
endCount: form.endCondition === "AFTER_COUNT" ? Number(form.endCount || 0) || null : null,
endDate: form.endCondition === "BY_DATE" ? form.endDate || null : null,
nextRunAt
});
if (createdEntry) {
setForm({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
isRecurring: false,
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: ""
});
setIsModalOpen(false);
notify({
title: "Entry added",
message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`,
tone: "success"
});
emitEntryMutated({ before: null, after: createdEntry });
}
}
function setDetailsFromEntry(entry: typeof entries[number], index: number) {
const nextId = Number(entry.id);
if (!Number.isFinite(nextId) || nextId <= 0) {
alert("Invalid entry id");
return;
}
const nextForm = {
amountDollars: String(entry.amountDollars),
occurredAt: new Date(entry.occurredAt).toISOString().slice(0, 10),
necessity: entry.necessity,
notes: entry.notes || "",
tags: entry.tags || [],
entryType: entry.entryType,
isRecurring: entry.isRecurring,
frequency: entry.frequency || "MONTHLY",
intervalCount: entry.intervalCount || 1,
endCondition: entry.endCondition || "NEVER",
endCount: entry.endCount ? String(entry.endCount) : "",
endDate: entry.endDate || ""
};
setSelectedId(nextId);
setSelectedIndex(index);
setRemovedTags([]);
setDetailsForm(nextForm);
setDetailsOriginal(nextForm);
setDetailsOpen(true);
}
function handleOpenDetails(entry: typeof entries[number], index: number) {
requestDiscard({ type: "open", entry, index });
}
function normalizeTags(tags: string[]) {
return tags.map(tag => tag.toLowerCase()).sort();
}
function handleFilterAddTag(tag: string) {
setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }));
}
function handleFilterToggleTag(tag: string) {
setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }));
}
function handleClearFilters() {
setFilters(emptyFilters);
}
function hasDetailsChanges() {
if (!detailsOriginal) return false;
const currentTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
const currentTagsKey = normalizeTags(currentTags).join("|");
const originalTagsKey = normalizeTags(detailsOriginal.tags).join("|");
return (
detailsForm.amountDollars !== detailsOriginal.amountDollars ||
detailsForm.occurredAt !== detailsOriginal.occurredAt ||
detailsForm.necessity !== detailsOriginal.necessity ||
detailsForm.notes !== detailsOriginal.notes ||
detailsForm.entryType !== detailsOriginal.entryType ||
detailsForm.isRecurring !== detailsOriginal.isRecurring ||
detailsForm.frequency !== detailsOriginal.frequency ||
detailsForm.intervalCount !== detailsOriginal.intervalCount ||
detailsForm.endCondition !== detailsOriginal.endCondition ||
detailsForm.endCount !== detailsOriginal.endCount ||
detailsForm.endDate !== detailsOriginal.endDate ||
currentTagsKey !== originalTagsKey
);
}
function requestDiscard(action: NonNullable<typeof pendingDiscardRef.current>) {
if (!detailsOpen || !hasDetailsChanges()) {
runDiscardAction(action);
return;
}
pendingDiscardRef.current = action;
setDiscardOpen(true);
}
function runDiscardAction(action: NonNullable<typeof pendingDiscardRef.current>) {
if (action.type === "close") {
setDetailsOpen(false);
setDetailsOriginal(null);
setRemovedTags([]);
return;
}
if (action.type === "open") {
setDetailsFromEntry(action.entry, action.index);
return;
}
if (!totalEntries) return;
const current = selectedIndex ?? 0;
if (action.type === "prev") {
const nextIndex = current === 0 ? totalEntries - 1 : current - 1;
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
}
if (action.type === "next") {
const nextIndex = current === totalEntries - 1 ? 0 : current + 1;
setDetailsFromEntry(visibleEntries[nextIndex], nextIndex);
}
}
async function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedId) return;
if (!hasDetailsChanges()) return;
const amountDollars = Number(detailsForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
alert("Enter a valid amount");
return;
}
if (!detailsForm.occurredAt) {
alert("Select a date");
return;
}
const nextTags = detailsForm.tags.filter(tag => !removedTags.includes(tag));
if (!nextTags.length) {
alert("Add at least one tag");
return;
}
const purchaseType = nextTags.join(", ") || "General";
const nextRunAt = detailsForm.isRecurring ? detailsForm.occurredAt : null;
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
const updatedEntry = await updateEntry({
id: selectedId,
entryType: detailsForm.entryType,
amountDollars,
occurredAt: detailsForm.occurredAt,
necessity: detailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: detailsForm.notes.trim() || undefined,
tags: nextTags,
isRecurring: detailsForm.isRecurring,
frequency: detailsForm.isRecurring ? detailsForm.frequency : null,
intervalCount: detailsForm.intervalCount,
endCondition: detailsForm.isRecurring ? detailsForm.endCondition : null,
endCount: detailsForm.endCondition === "AFTER_COUNT" ? Number(detailsForm.endCount || 0) || null : null,
endDate: detailsForm.endCondition === "BY_DATE" ? detailsForm.endDate || null : null,
nextRunAt
});
if (updatedEntry) {
setDetailsOpen(false);
setDetailsOriginal(null);
setRemovedTags([]);
notify({
title: "Entry updated",
message: `${nextTags.join(", ")} · $${amountDollars.toFixed(2)}`
});
emitEntryMutated({ before: beforeEntry, after: updatedEntry });
}
}
async function handleDelete() {
if (!selectedId || !Number.isFinite(selectedId)) return;
const beforeEntry = entries.find(entry => Number(entry.id) === Number(selectedId)) || null;
const deletedEntry = await deleteEntry(selectedId);
if (deletedEntry || beforeEntry) {
setDetailsOpen(false);
setDetailsOriginal(null);
setRemovedTags([]);
notify({
title: "Entry deleted",
message: detailsForm.tags.join(", ") || "Entry removed",
tone: "danger"
});
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
}
}
function handleToggleTag(tag: string) {
setRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]);
}
function handleAddTag(tag: string) {
setDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
setRemovedTags(prev => prev.filter(item => item !== tag));
}
function handlePrev() {
if (!totalEntries) return;
requestDiscard({ type: "prev" });
}
function handleNext() {
if (!totalEntries) return;
requestDiscard({ type: "next" });
}
function handleCloseDetails() {
requestDiscard({ type: "close" });
}
function handleConfirmDiscard() {
const action = pendingDiscardRef.current;
pendingDiscardRef.current = null;
setDiscardOpen(false);
if (action) runDiscardAction(action);
}
function handleCancelDiscard() {
pendingDiscardRef.current = null;
setDiscardOpen(false);
}
function handleRevertDetails() {
if (!detailsOriginal) return;
setDetailsForm(detailsOriginal);
setRemovedTags([]);
}
return (
<>
<div className="space-y-4">
<div className="panel panel-accent p-4">
<div className="card-header">
<div className="flex flex-wrap items-center gap-3">
<h2 className="card-title text-lg">Entries</h2>
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
>
Existing
</button>
<button
type="button"
className={`rounded-full w-20 px-3 py-2 text-xs font-semibold ${entryTab === "RECURRING" ? "btn-accent" : "text-muted"}`}
onClick={() => setEntryTab(prev => prev === "RECURRING" ? "SINGLE" : "RECURRING")}
>
Scheduled
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFilterOpen(true)}
className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50"
disabled={!activeGroupId}
>
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
</button>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
disabled={!activeGroupId}
aria-label="Add entry"
>
+
</button>
</div>
</div>
<div className="mt-3 space-y-2">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view entries.</div>
) : loading ? (
<div className="space-y-2">
{[0, 1, 2].map(row => (
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
<div className="animate-pulse space-y-2">
<div className="h-4 w-28 rounded bg-surface" />
<div className="h-3 w-40 rounded bg-surface" />
<div className="flex flex-wrap gap-2">
<div className="h-5 w-14 rounded-full bg-surface" />
<div className="h-5 w-12 rounded-full bg-surface" />
<div className="h-5 w-16 rounded-full bg-surface" />
</div>
</div>
</div>
))}
</div>
) : entries.length ? (
visibleEntries.length ? (
visibleEntries.map((entry, index) => {
const tags = entry.tags ?? [];
const mobileTagLimit = 2;
const mobileTags = tags.slice(0, mobileTagLimit);
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
return (
<div
key={entry.id}
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
onClick={() => handleOpenDetails(entry, index)}
>
<div className="flex flex-col gap-1">
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
<div className="text-xs text-muted">
{new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity}
</div>
</div>
{tags.length ? (
<>
<div className="flex flex-wrap justify-end gap-2 md:hidden">
{mobileTags.map(tag => (
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
#{tag}
</span>
))}
{extraTagCount ? (
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
{extraTagCount} more...
</span>
) : null}
</div>
<div className="hidden flex-wrap justify-end gap-2 md:flex">
{tags.map(tag => (
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
#{tag}
</span>
))}
</div>
</>
) : (
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
)}
</div>
);
})
) : (
<div className="space-y-2 text-sm text-muted">
<div>No matching entries.</div>
{activeFilterCount ? (
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={handleClearFilters}>
Clear filters
</button>
) : null}
</div>
)
) : (
<div className="text-sm text-muted">No entries yet.</div>
)}
</div>
</div>
</div>
<NewEntryModal
isOpen={isModalOpen && Boolean(activeGroupId)}
form={form}
error={error}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreate}
onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
amountInputRef={amountInputRef}
tagsInputRef={tagsInputRef}
/>
<EntryDetailsModal
isOpen={detailsOpen}
form={detailsForm}
originalForm={detailsOriginal}
isDirty={hasDetailsChanges()}
error={error}
onClose={handleCloseDetails}
onSubmit={handleUpdate}
onRequestDelete={() => setConfirmDeleteOpen(true)}
onRevert={handleRevertDetails}
onChange={next => setDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={handleAddTag}
onToggleTag={handleToggleTag}
removedTags={removedTags}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
onPrev={handlePrev}
onNext={handleNext}
loopHintPrev={selectedIndex === 0 && totalEntries > 1 ? "Loop" : ""}
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
canNavigate={totalEntries > 1}
/>
{filterOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
<div
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") setFilterOpen(false);
}}
role="dialog"
tabIndex={-1}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Filter Entries</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFilterOpen(false)}
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
</button>
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="text-sm text-muted md:col-span-2">
Amount Range
<div className="mt-1 flex items-center gap-2">
<input
type="number"
min={0}
step="0.01"
className="w-full input-base px-3 py-2 text-sm"
value={filters.amountMin}
placeholder="none"
onChange={e => setFilters(prev => ({ ...prev, amountMin: e.target.value }))}
/>
<span className="text-xs text-soft">-</span>
<input
type="number"
min={0}
step="0.01"
className="w-full input-base px-3 py-2 text-sm"
value={filters.amountMax}
placeholder="none"
onChange={e => setFilters(prev => ({ ...prev, amountMax: e.target.value }))}
/>
</div>
</label>
<label className="text-sm text-muted md:col-span-2">
Date Range
<div className="mt-1 flex items-center gap-2">
<input
type="date"
className="no-date-icon w-full input-base px-3 py-2 text-sm"
value={filters.dateFrom}
placeholder="none"
onChange={e => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
/>
<span className="text-xs text-soft">-</span>
<input
type="date"
className="no-date-icon w-full input-base px-3 py-2 text-sm"
value={filters.dateTo}
placeholder="none"
onChange={e => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
/>
</div>
</label>
<div className="text-sm text-muted">
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
{([
{ value: "ANY", label: "Any" },
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.necessity === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => setFilters(prev => ({ ...prev, necessity: prev.necessity === option.value ? "ANY" : option.value }))}
>
{option.label}
</button>
))}
</div>
</div>
<label className="text-sm text-muted">
Notes contains
<input
type="text"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={filters.notesQuery}
onChange={e => setFilters(prev => ({ ...prev, notesQuery: e.target.value }))}
/>
</label>
</div>
<div className="mt-3 space-y-3">
<TagInput
label="Tags"
labelAction={
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ANY" ? "btn-accent" : "text-muted"}`}
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ANY" ? "ALL" : "ANY" }))}
>
Any
</button>
<button
type="button"
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ALL" ? "btn-accent" : "text-muted"}`}
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ALL" ? "ANY" : "ALL" }))}
>
All
</button>
</div>
}
tags={filters.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={handleFilterToggleTag}
onAddTag={handleFilterAddTag}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={!canManageTags}
onEmptySuggestionClick={handleEmptyTagAction}
/>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-soft">
{activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"}
</div>
<div className="flex items-center gap-2">
<button type="button" className="rounded-lg btn-outline-accent px-3 py-2 text-xs" onClick={handleClearFilters}>
Clear Filters
</button>
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={() => setFilterOpen(false)}>
Apply
</button>
</div>
</div>
</div>
</div>
) : null}
{discardOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl"
onKeyDown={event => {
if (event.key === "Escape") handleCancelDiscard();
}}
role="dialog"
tabIndex={-1}
>
<div className="text-lg font-semibold">Discard changes?</div>
<p className="mt-2 text-sm text-muted">You have unsaved changes. Do you want to discard them?</p>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={handleCancelDiscard}
>
Keep editing
</button>
<button
type="button"
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
onClick={handleConfirmDiscard}
>
Discard
</button>
</div>
</div>
</div>
) : null}
<ConfirmSlideModal
isOpen={confirmDeleteOpen}
title="Delete entry"
description="This will permanently remove the entry and its tags."
confirmLabel="Delete entry"
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => {
setConfirmDeleteOpen(false);
handleDelete();
}}
/>
</>
);
}

View File

@ -0,0 +1,387 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
export type EntryDetailsForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
isRecurring: boolean;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: string;
endDate: string;
};
type EntryDetailsModalProps = {
isOpen: boolean;
form: EntryDetailsForm;
originalForm: EntryDetailsForm | null;
isDirty: boolean;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onRequestDelete: () => void;
onRevert: () => void;
onChange: (next: Partial<EntryDetailsForm>) => void;
onAddTag: (tag: string) => void;
onToggleTag: (tag: string) => void;
removedTags: string[];
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
onPrev: () => void;
onNext: () => void;
loopHintPrev: string;
loopHintNext: string;
canNavigate: boolean;
};
export default function EntryDetailsModal({
isOpen,
form,
originalForm,
isDirty,
error,
onClose,
onSubmit,
onRequestDelete,
onRevert,
onChange,
onAddTag,
onToggleTag,
removedTags,
tagSuggestions,
emptyTagActionLabel,
emptyTagActionDisabled = false,
onEmptyTagAction,
onPrev,
onNext,
loopHintPrev,
loopHintNext,
canNavigate
}: EntryDetailsModalProps) {
const baseline = originalForm ?? form;
const removedSet = new Set(removedTags.map(tag => tag.toLowerCase()));
const currentTags = form.tags.filter(tag => !removedSet.has(tag.toLowerCase()));
const normalizeTags = (tags: string[]) => tags.map(tag => tag.toLowerCase()).sort().join("|");
const baselineTags = baseline.tags || [];
const tagsChanged = normalizeTags(currentTags) !== normalizeTags(baselineTags);
const addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
const amountChanged = form.amountDollars !== baseline.amountDollars;
const dateChanged = form.occurredAt !== baseline.occurredAt;
const necessityChanged = form.necessity !== baseline.necessity;
const notesChanged = form.notes !== baseline.notes;
const changedInputClass = "border-2 border-[color:var(--color-accent)]";
const formRef = useRef<HTMLFormElement | null>(null);
const touchStartX = useRef<number | null>(null);
const touchDeltaX = useRef(0);
function handleTouchStart(event: React.TouchEvent<HTMLDivElement>) {
touchStartX.current = event.touches[0]?.clientX ?? null;
touchDeltaX.current = 0;
}
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
if (touchStartX.current === null) return;
touchDeltaX.current = event.touches[0]?.clientX - touchStartX.current;
}
function handleTouchEnd() {
if (!canNavigate) return;
const delta = touchDeltaX.current;
touchStartX.current = null;
touchDeltaX.current = 0;
if (Math.abs(delta) < 60) return;
if (delta > 0) onPrev();
else onNext();
}
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="grid grid-cols-3 items-center gap-3">
<button
type="button"
onClick={onPrev}
className="flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Previous entry"
>
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
</button>
<h2 className="text-center text-lg font-semibold">Entry details</h2>
<button
type="button"
onClick={onNext}
className="ml-auto flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Next entry"
>
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
</button>
</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
<button
type="button"
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => onChange({ isRecurring: !form.isRecurring })}
title="Toggle Recurring Entry"
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
>
<span aria-hidden></span>
</button>
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
>
Spending
</button>
<button
type="button"
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
>
Income
</button>
</div>
</div>
<label className="text-sm text-muted">
Amount ($)
<input
name="amountDollars"
type="number"
min={0}
step="0.01"
className={`mt-1 w-full input-base px-3 py-2 text-sm ${amountChanged ? changedInputClass : ""} ${form.amountDollars ? "" : "border-red-400/70"}`}
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</label>
<div className="text-sm text-muted">
<input
name="occurredAt"
type="date"
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
value={form.occurredAt}
onChange={e => onChange({ occurredAt: e.target.value })}
required
/>
</div>
<div className="text-sm text-muted">
<div className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
{([
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ necessity: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
<TagInput
label="Tags"
tags={form.tags}
removedTags={removedTags}
highlightTags={addedTags}
suggestions={tagSuggestions}
allowCustom={false}
chipsBelow
onToggleTag={onToggleTag}
onAddTag={onAddTag}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={emptyTagActionDisabled}
onEmptySuggestionClick={onEmptyTagAction}
invalid={!currentTags.length}
/>
{form.isRecurring ? (
<div className="md:col-span-2">
<div className="text-sm text-muted">Frequency Conditions</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
<input
type="number"
min={1}
className="w-20 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="BIWEEKLY">biweekly</option>
<option value="MONTHLY">month(s)</option>
<option value="QUARTERLY">quarter(s)</option>
<option value="YEARLY">year(s)</option>
</select>
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
{([
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ endCondition: option.value })}
>
{option.label}
</button>
))}
</div>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() - 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
<input
type="date"
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
value={form.endDate}
onChange={e => onChange({ endDate: e.target.value })}
/>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
</div>
) : null}
</div>
</div>
) : null}
<label className="text-sm text-muted md:col-span-2">
Notes
<textarea
name="notes"
className={`mt-1 w-full input-base px-3 py-2 text-sm ${notesChanged ? changedInputClass : ""}`}
rows={3}
value={form.notes}
onChange={e => onChange({ notes: e.target.value })}
placeholder="Optional"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRevert}
disabled={!isDirty}
aria-label="Revert changes"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v4h4" />
</svg>
</button>
<button
type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
disabled={!isDirty}
>
Save changes
</button>
<button
type="button"
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
onClick={onRequestDelete}
>
Delete
</button>
<button
type="button"
onClick={onClose}
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
aria-label="Close"
>
Close
</button>
</div>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,300 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
type GroupDropdownProps = {
onInviteCode: (code: string) => void;
};
export default function GroupDropdown({ onInviteCode }: GroupDropdownProps) {
const { groups, activeGroupId, loading, error, createGroup, joinGroup, setActiveGroup } = useGroupsContext();
const { notify } = useNotificationsContext();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"create" | "join">("create");
const [name, setName] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [localError, setLocalError] = useState("");
const dropdownRef = useRef<HTMLDivElement | null>(null);
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
const groupLabel = loading ? "Loading" : activeGroup ? activeGroup.name : "Select group";
async function handleCreate() {
if (!name.trim()) {
setLocalError("Group name is required");
return;
}
setLocalError("");
const group = await createGroup({ name });
if (group?.inviteCode) onInviteCode(group.inviteCode);
if (group) {
setName("");
notify({ title: "Group created", message: group.name, tone: "success" });
setManageOpen(false);
setOpen(false);
router.push("/groups/settings");
}
}
async function handleJoin() {
if (!inviteCode.trim()) {
setLocalError("Invite code is required");
return;
}
setLocalError("");
const raw = inviteCode.trim();
const inviteTokenMatch = raw.match(/\/invite\/([a-zA-Z0-9]+)/);
if (inviteTokenMatch?.[1]) {
setInviteCode("");
setManageOpen(false);
setOpen(false);
router.push(`/invite/${inviteTokenMatch[1]}`);
return;
}
const group = await joinGroup({ inviteCode: raw });
if (group) {
setInviteCode("");
notify({ title: "Joined group", message: group.name, tone: "success" });
setManageOpen(false);
setOpen(false);
}
}
async function handleSelect(groupId: number) {
const ok = await setActiveGroup(groupId);
if (ok) {
setOpen(false);
const group = groups.find(item => item.id === groupId);
if (group) notify({ title: "Active group", message: group.name });
if (pathname.startsWith("/groups/")) router.push("/groups/settings");
}
}
function handleQuickEntries() {
setOpen(false);
router.push("/");
}
function roleIcon(role: string) {
if (role === "GROUP_OWNER") return "👑";
if (role === "GROUP_ADMIN") return "🛡️";
return "👤";
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!open || !dropdownRef.current) return;
if (!dropdownRef.current.contains(event.target as Node))
setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
useEffect(() => {
if (!manageOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setManageOpen(false);
if (event.key === "Enter" && !event.shiftKey) {
if (activeTab === "create") handleCreate();
else handleJoin();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [manageOpen, activeTab, handleCreate, handleJoin]);
return (
<div className="relative" ref={dropdownRef}>
<div className="inline-flex items-center">
<button
type="button"
aria-label="Back to entries"
onClick={handleQuickEntries}
className="flex h-9 w-9 items-center justify-center rounded-l-lg border border-accent-weak bg-panel text-sm hover:border-accent"
disabled={loading || !activeGroupId}
>
$
{/* <svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 6l-6 6 6 6" />
</svg> */}
</button>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={`-ml-px flex h-9 w-44 items-center justify-between rounded-none border border-accent-weak bg-panel px-3 text-sm hover:border-accent sm:w-56 ${loading ? "animate-pulse" : ""}`}
>
<span className="truncate">{groupLabel}</span>
<span className="text-l font-bold text-soft"></span>
</button>
<button
type="button"
aria-label="Group settings"
onClick={() => {
if (!activeGroupId) return;
setOpen(false);
router.push("/groups/settings");
}}
disabled={loading || !activeGroupId}
className="-ml-px flex h-9 w-9 items-center justify-center rounded-r-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-60"
>
<span className="text-sm"></span>
</button>
</div>
{open ? (
<div className="absolute left-1/2 mt-2 w-64 -translate-x-1/2 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
<div className="space-y-2">
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(row => (
<div key={row} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-2 py-2">
<div className="h-3 w-32 rounded bg-surface animate-pulse" />
<div className="h-3 w-10 rounded bg-surface animate-pulse" />
</div>
))}
</div>
) : groups.length ? (
groups.map(group => (
<div
key={group.id}
role="button"
tabIndex={0}
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left outline-none ${activeGroupId === group.id ? "bg-accent-soft" : "hover:bg-surface"}`}
onClick={() => handleSelect(group.id)}
onKeyDown={event => {
if (event.key === "Enter" || event.key === " ") handleSelect(group.id);
}}
>
<span className="truncate">{group.name}</span>
<span className="text-xs text-soft" aria-label={group.role}>
{roleIcon(group.role)}
</span>
</div>
))
) : (
<div className="text-soft">No groups yet</div>
)}
</div>
<div className="my-3 h-px divider" />
<div className="space-y-2">
<button
type="button"
className="w-full rounded-lg btn-accent px-2 py-2 text-sm font-semibold disabled:opacity-60"
onClick={() => {
setManageOpen(true);
setActiveTab("create");
setOpen(false);
}}
disabled={loading}
>
Create or join group
</button>
{localError || error ? (
<div className="text-xs text-red-400">{localError || error}</div>
) : null}
</div>
</div>
) : null}
{manageOpen ? (
<div className="fixed inset-0 z-[60] flex min-h-[100dvh] items-center justify-center bg-black/60 p-4" onClick={() => setManageOpen(false)}>
<div
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") setManageOpen(false);
if (event.key === "Enter" && !event.shiftKey) {
if (activeTab === "create") handleCreate();
else handleJoin();
}
}}
role="dialog"
tabIndex={-1}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Manage groups</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={() => setManageOpen(false)}
>
Close
</button>
</div>
<div className="mt-4 flex items-center gap-2">
{([
{ key: "create", label: "Create" },
{ key: "join", label: "Join" }
] as const).map(tab => (
<button
key={tab.key}
type="button"
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-semibold ${activeTab === tab.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{activeTab === "create" ? (
<div className="mt-4 space-y-2">
<input
type="text"
placeholder="Group name"
className={`w-full input-base px-3 py-2 text-sm ${name.trim() ? "" : "border-red-400/70"}`}
value={name}
onChange={e => setName(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={handleCreate}
disabled={loading}
>
Create group
</button>
</div>
) : (
<div className="mt-4 space-y-2">
<input
type="text"
placeholder="Invite link or code"
className={`w-full input-base px-3 py-2 text-sm ${inviteCode.trim() ? "" : "border-red-400/70"}`}
value={inviteCode}
onChange={e => setInviteCode(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={handleJoin}
disabled={loading}
>
Join group
</button>
</div>
)}
{localError || error ? (
<div className="mt-3 text-xs text-red-400">{localError || error}</div>
) : null}
</div>
</div>
) : null}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,186 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import GroupDropdown from "@/components/group-dropdown";
import { useAuthContext } from "@/hooks/auth-context";
import { useGroupsContext } from "@/hooks/groups-context";
export default function Navbar() {
const router = useRouter();
const { checkSession, logout } = useAuthContext();
const { activeGroupId } = useGroupsContext();
const [inviteModalCode, setInviteModalCode] = useState<string | null>(null);
const [inviteCopied, setInviteCopied] = useState(false);
const [hideNavbar, setHideNavbar] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const userMenuRef = useRef<HTMLDivElement | null>(null);
async function handleCopyInvite() {
if (!inviteModalCode) return;
await navigator.clipboard.writeText(inviteModalCode);
setInviteCopied(true);
}
async function handleLogout() {
await logout();
setUserMenuOpen(false);
router.push("/login");
}
useEffect(() => {
let active = true;
async function loadUser() {
const user = await checkSession();
if (active) setUserEmail(user?.email || null);
}
loadUser();
return () => {
active = false;
};
}, [checkSession]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!userMenuOpen || !userMenuRef.current) return;
if (!userMenuRef.current.contains(event.target as Node))
setUserMenuOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [userMenuOpen]);
useEffect(() => {
if (!inviteModalCode) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setInviteModalCode(null);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [inviteModalCode]);
useEffect(() => {
let lastY = window.scrollY;
function onScroll() {
const current = window.scrollY;
const diff = current - lastY;
if (Math.abs(diff) < 6) return;
if (current <= 8) {
setHideNavbar(false);
} else if (diff > 0) {
setHideNavbar(true);
} else {
setHideNavbar(false);
}
lastY = current;
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<>
<header className={`sticky top-0 z-40 border-b border-accent-weak bg-app backdrop-blur transition-transform duration-200 ${hideNavbar ? "-translate-y-full" : "translate-y-0"}`}>
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl border border-accent bg-panel hover:border-accent-strong"
aria-label="Settings"
onClick={() => {
if (activeGroupId) router.push("/groups/settings");
else router.push("/");
}}
>
<img src="/icons/navbar-settings.png" alt="" className="h-full w-full rounded-lg object-cover" />
</button>
<div className="hidden sm:block text-sm font-semibold">Fiddy</div>
</div>
<GroupDropdown
onInviteCode={code => {
setInviteModalCode(code);
setInviteCopied(false);
}}
/>
<div className="relative" ref={userMenuRef}>
<button
type="button"
aria-label="User menu"
onClick={() => setUserMenuOpen(prev => !prev)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-accent-weak bg-panel text-sm text-muted hover:border-accent"
>
<span className="text-base">👤</span>
</button>
{userMenuOpen ? (
<div className="absolute right-0 mt-2 w-48 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
<div className="text-xs text-soft">Signed in as</div>
<div className="mt-1 truncate text-sm font-semibold text-[color:var(--color-text)]">
{userEmail || "User"}
</div>
<div className="my-3 h-px divider" />
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
onClick={() => {
setUserMenuOpen(false);
if (activeGroupId) router.push("/groups/settings");
else router.push("/");
}}
>
Settings
</button>
<button
type="button"
className="mt-2 w-full rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-200"
onClick={handleLogout}
>
Logout
</button>
</div>
) : null}
</div>
</div>
</header>
{inviteModalCode ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-6 overflow-y-auto">
<div
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl max-h-[90vh]"
onKeyDown={event => {
if (event.key === "Escape") setInviteModalCode(null);
}}
role="dialog"
tabIndex={-1}
>
<div className="text-lg font-semibold">Invite code</div>
<p className="mt-2 text-sm text-muted">
Share this code to invite members. You can view it later in group settings.
</p>
<div className="mt-4 rounded-lg border border-accent-weak bg-surface px-3 py-2 text-center text-lg tracking-widest">
{inviteModalCode}
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
onClick={handleCopyInvite}
>
{inviteCopied ? "Copied" : "Copy code"}
</button>
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => setInviteModalCode(null)}
>
Close
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -0,0 +1,222 @@
"use client";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import TagInput from "@/components/tag-input";
import { bucketIcons } from "@/lib/shared/bucket-icons";
type BucketForm = {
name: string;
description: string;
iconKey: string;
budgetLimitDollars: string;
tags: string[];
necessity: string;
windowDays: string;
};
type NewBucketModalProps = {
isOpen: boolean;
title: string;
form: BucketForm;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<BucketForm>) => void;
tagSuggestions: string[];
};
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) {
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconSearch, setIconSearch] = useState("");
const formRef = useRef<HTMLFormElement | null>(null);
const normalizedSearch = iconSearch.trim().toLowerCase();
const filteredIcons = useMemo(() => {
if (!normalizedSearch) return bucketIcons;
return bucketIcons.filter(item => item.label.toLowerCase().includes(normalizedSearch) || item.key.toLowerCase().includes(normalizedSearch));
}, [normalizedSearch]);
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
const selectedIcon = form.iconKey ? iconMap.get(form.iconKey) : iconMap.get("none");
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (iconModalOpen) setIconModalOpen(false);
else onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [iconModalOpen, isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
</button>
<div className="text-lg font-semibold">{title}</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<label className="text-sm text-muted md:col-span-2">
<div className="mt-1 flex items-center gap-2">
<div
className="flex h-10 w-12 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg"
onClick={() => setIconModalOpen(true)}
>
{selectedIcon || "🚫"}
</div>
<input
name="name"
className={`w-full input-base px-3 py-2 text-sm ${form.name.trim() ? "" : "border-red-400/70"}`}
value={form.name}
onChange={e => onChange({ name: e.target.value })}
required
/>
</div>
</label>
<label className="text-sm text-muted">
Budget limit ($)
<input
name="budgetLimitDollars"
type="number"
min={0}
step="0.01"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={form.budgetLimitDollars}
placeholder="none"
onChange={e => onChange({ budgetLimitDollars: e.target.value })}
/>
</label>
<label className="text-sm text-muted">
Window days
<input
name="windowDays"
type="number"
min={1}
max={365}
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={form.windowDays}
onChange={e => onChange({ windowDays: e.target.value })}
/>
</label>
<div className="text-sm text-muted md:col-span-2">
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
{([
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ necessity: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
<label className="text-sm text-muted md:col-span-2">
Description
<textarea
name="description"
className="mt-1 w-full input-base px-3 py-2 text-sm"
rows={2}
value={form.description}
onChange={e => onChange({ description: e.target.value })}
/>
</label>
<TagInput
label="Bucket tags"
tags={form.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
/>
<div className="md:col-span-2 flex items-center justify-between">
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
Save bucket
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
{iconModalOpen ? (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4" onClick={() => setIconModalOpen(false)}>
<div className="w-full max-w-lg rounded-2xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Pick an icon</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={() => setIconModalOpen(false)}
aria-label="Close"
>
</button>
</div>
<input
type="text"
className="mt-3 w-full input-base px-3 py-2 text-sm"
placeholder="Search icons"
value={iconSearch}
onChange={e => setIconSearch(e.target.value)}
/>
<div className="mt-3 max-h-[50vh] overflow-auto rounded-lg border border-accent-weak bg-surface p-2">
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
{filteredIcons.map(item => (
<button
key={item.key}
type="button"
className={`flex h-10 w-10 items-center justify-center rounded-lg border text-lg ${form.iconKey === item.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
onClick={() => {
onChange({ iconKey: item.key });
setIconModalOpen(false);
}}
aria-label={item.label}
title={item.label}
>
{item.icon}
</button>
))}
</div>
{!filteredIcons.length ? (
<div className="py-6 text-center text-sm text-soft">No matching icons.</div>
) : null}
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,297 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
type NewEntryForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
isRecurring: boolean;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: string;
endDate: string;
};
type NewEntryModalProps = {
isOpen: boolean;
form: NewEntryForm;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<NewEntryForm>) => void;
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
amountInputRef?: React.Ref<HTMLInputElement>;
tagsInputRef?: React.Ref<HTMLInputElement>;
};
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
if (!form.occurredAt) {
const today = new Date().toISOString().slice(0, 10);
onChange({ occurredAt: today, endDate: form.endDate || today });
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
function shiftDate(days: number) {
const base = form.occurredAt ? new Date(form.occurredAt) : new Date();
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + days);
onChange({ occurredAt: base.toISOString().slice(0, 10) });
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
<button
type="button"
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
>
Spending
</button>
<button
type="button"
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
>
Income
</button>
</div>
<button
type="button"
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => onChange({ isRecurring: !form.isRecurring })}
title="Toggle Recurring Entry"
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
>
<span aria-hidden></span>
</button>
</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<label className="text-sm text-muted">
{/* Amount ($) */}
<div className="relative mt-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
</span>
<input
ref={amountInputRef}
name="amountDollars"
type="number"
min={0}
step="0.01"
className={`w-full input-base px-12 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</div>
</label>
<div className="text-sm text-muted">
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}></button>
<input
name="occurredAt"
type="date"
className="no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none"
value={form.occurredAt}
onChange={e => onChange({ occurredAt: e.target.value })}
required
/>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}></button>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
</div>
</div>
<div className="text-sm text-muted">
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
{([
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ necessity: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
{/* TAGS */}
<TagInput
label="Tags"
tags={form.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={emptyTagActionDisabled}
onEmptySuggestionClick={onEmptyTagAction}
invalid={!form.tags.length}
inputRef={tagsInputRef}
/>
{/* RECURRING OPTIONS */}
{form.isRecurring ? (
<div className="md:col-span-2">
<div className="text-sm text-muted">Frequency Conditions</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
<input
type="number"
min={1}
className="w-14 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="MONTHLY">month(s)</option>
<option value="YEARLY">year(s)</option>
</select>
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
{([
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
] as const).map(option => (
<button
key={option.value}
type="button"
className={`rounded-full px-3 py-3 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
onClick={() => onChange({ endCondition: option.value })}
>
{option.label}
</button>
))}
</div>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() - 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
<input
type="date"
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
value={form.endDate}
onChange={e => onChange({ endDate: e.target.value })}
/>
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + 1);
onChange({ endDate: base.toISOString().slice(0, 10) });
}}></button>
</div>
) : null}
</div>
</div>
) : null}
<label className="text-sm text-muted md:col-span-2">
Notes
<textarea
name="notes"
className="mt-1 w-full input-base px-3 py-2 text-sm"
rows={3}
value={form.notes}
onChange={e => onChange({ notes: e.target.value })}
placeholder="Optional"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between">
<button
type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
>
Add entry
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div >
);
}

View File

@ -0,0 +1,55 @@
"use client";
import type { ReactNode } from "react";
export type NotificationTone = "info" | "success" | "danger";
export type NotificationItem = {
id: string;
title: string;
message?: string;
tone: NotificationTone;
closing: boolean;
};
type NotificationsToasterProps = {
items: NotificationItem[];
onDismiss: (id: string) => void;
};
function toneClasses(tone: NotificationTone) {
if (tone === "success") return "border-emerald-400/40 text-emerald-200";
if (tone === "danger") return "border-red-400/50 text-red-200";
return "border-accent-weak text-[color:var(--color-text)]";
}
export default function NotificationsToaster({ items, onDismiss }: NotificationsToasterProps) {
if (!items.length) return null;
return (
<div className="fixed bottom-4 right-4 z-[60] flex w-80 flex-col-reverse gap-2 pointer-events-none">
{items.map(item => (
<div
key={item.id}
className={`pointer-events-auto rounded-xl border bg-panel px-3 py-2 text-sm shadow-lg transition-all duration-300 ${toneClasses(item.tone)} ${item.closing ? "opacity-0 translate-y-2" : "opacity-100"}`}
role="status"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold">{item.title}</div>
{item.message ? <div className="text-xs text-soft">{item.message}</div> : null}
</div>
<button
type="button"
className="rounded-md border border-accent-weak px-1.5 py-0.5 text-xs text-soft hover:border-accent"
onClick={() => onDismiss(item.id)}
aria-label="Dismiss notification"
>
Close
</button>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useMemo } from "react";
import useEntries from "@/hooks/use-entries";
import { useGroupsContext } from "@/hooks/groups-context";
function monthlyMultiplier(frequency: string, intervalCount: number) {
const count = intervalCount || 1;
switch (frequency) {
case "DAILY":
return (30 / count);
case "WEEKLY":
return (52 / 12) / count;
case "BIWEEKLY":
return (26 / 12) / count;
case "MONTHLY":
return (1 / count);
case "QUARTERLY":
return (1 / 3) / count;
case "YEARLY":
return (1 / 12) / count;
default:
return 0;
}
}
export default function RecurringEntriesPanel() {
const { activeGroupId } = useGroupsContext();
const { entries, loading } = useEntries(activeGroupId);
const recurring = useMemo(() => entries.filter(entry => entry.isRecurring), [entries]);
return (
<div className="panel panel-accent p-4">
<div className="card-header">
<h2 className="card-title text-lg">Recurring entries</h2>
</div>
<div className="mt-3 space-y-2">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view recurring entries.</div>
) : loading ? (
<div className="space-y-2">
{[0, 1].map(row => (
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
<div className="animate-pulse space-y-2">
<div className="h-4 w-28 rounded bg-surface" />
<div className="h-3 w-40 rounded bg-surface" />
</div>
</div>
))}
</div>
) : recurring.length ? (
recurring.map(entry => {
const monthly = entry.frequency ? monthlyMultiplier(entry.frequency, entry.intervalCount) * entry.amountDollars : 0;
return (
<div key={entry.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="font-semibold">${entry.amountDollars.toFixed(2)} · {entry.tags.join(", ") || "No tags"}</div>
<div className="text-xs text-soft">{entry.entryType}</div>
</div>
<div className="mt-1 text-xs text-soft">
Next run: {entry.nextRunAt || entry.occurredAt} · Monthly est: ${monthly.toFixed(2)}
</div>
</div>
);
})
) : (
<div className="text-sm text-muted">No recurring entries yet.</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,260 @@
"use client";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
function normalizeTag(tag: string) {
return tag
.trim()
.replace(/^#/, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
type TagInputProps = {
label: string;
labelAction?: React.ReactNode;
tags: string[];
suggestions: string[];
removedTags?: string[];
highlightTags?: string[];
onToggleTag?: (tag: string) => void;
onAddTag: (tag: string) => void;
allowCustom?: boolean;
chipsBelow?: boolean;
emptySuggestionLabel?: string;
emptySuggestionDisabled?: boolean;
onEmptySuggestionClick?: () => void;
enableBackspaceRemove?: boolean;
invalid?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
};
export default function TagInput({ label, labelAction, tags, suggestions, removedTags = [], highlightTags = [], onToggleTag, onAddTag, allowCustom = true, chipsBelow = false, emptySuggestionLabel, emptySuggestionDisabled = false, onEmptySuggestionClick, enableBackspaceRemove = false, invalid = false, inputRef }: TagInputProps) {
const [value, setValue] = useState("");
const [desiredIndex, setDesiredIndex] = useState(0);
const [highlightIndex, setHighlightIndex] = useState(-1);
const internalInputRef = useRef<HTMLInputElement | null>(null);
const ignoreBlurRef = useRef(false);
const removedSet = useMemo(() => new Set(removedTags.map(tag => tag.toLowerCase())), [removedTags]);
const highlightSet = useMemo(() => new Set(highlightTags.map(tag => tag.toLowerCase())), [highlightTags]);
const normalizedSuggestions = useMemo(() => suggestions
.map(tag => ({
raw: String(tag),
normalized: normalizeTag(String(tag))
}))
.filter(item => item.normalized.length > 0), [suggestions]);
const suggestionSet = useMemo(() => new Set(normalizedSuggestions.map(item => item.normalized.toLowerCase())), [normalizedSuggestions]);
const filtered = useMemo(() => {
const raw = value.split(/[\n,]/).pop() ?? "";
const normalized = normalizeTag(raw);
if (!normalized) return [] as string[];
return normalizedSuggestions
.filter(item => item.normalized.toLowerCase().includes(normalized))
.filter(item => !tags.some(existing => normalizeTag(existing) === item.normalized))
.map(item => item.raw)
.slice(0, 6);
}, [normalizedSuggestions, tags, value]);
const showEmptySuggestion = !allowCustom && suggestions.length === 0 && Boolean(emptySuggestionLabel) && !filtered.length && value.trim().length > 0;
useEffect(() => {
if (!filtered.length) {
setHighlightIndex(-1);
return;
}
const next = Math.min(desiredIndex, filtered.length - 1);
setHighlightIndex(next);
}, [filtered.length, desiredIndex]);
function setInputRef(node: HTMLInputElement | null) {
internalInputRef.current = node;
if (!inputRef) return;
if (typeof inputRef === "function") {
inputRef(node);
return;
}
(inputRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
}
function commitTags(raw: string) {
const parts = raw.split(/[\n,]+/);
const nextTags: string[] = [];
const seen = new Set(tags.map(tag => tag.toLowerCase()));
for (const part of parts) {
const next = normalizeTag(part);
if (!next) continue;
const lower = next.toLowerCase();
if (seen.has(lower)) continue;
if (!allowCustom && !suggestionSet.has(lower)) continue;
seen.add(lower);
nextTags.push(next);
}
if (!nextTags.length) return false;
nextTags.forEach(tag => onAddTag(tag));
setValue("");
return true;
}
function addSuggestedTag(raw: string) {
const next = normalizeTag(raw);
if (!next) return false;
if (tags.some(tag => normalizeTag(tag) === next)) return false;
onAddTag(next);
setValue("");
return true;
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (false && event.key === "Backspace" && !value && tags.length) {
console.log("Backspace pressed with empty input, removing last tag");
event.preventDefault();
onToggleTag?.(tags[tags.length - 1]);
return;
}
if (event.key === "ArrowDown" && filtered.length) {
event.preventDefault();
const next = Math.min(desiredIndex + 1, filtered.length - 1);
setDesiredIndex(next);
setHighlightIndex(next);
return;
}
if (event.key === "ArrowUp" && filtered.length) {
event.preventDefault();
const next = Math.max(desiredIndex - 1, 0);
setDesiredIndex(next);
setHighlightIndex(next);
return;
}
if (event.key === "Enter" || event.key === "," || event.key === " ") {
event.preventDefault();
if (filtered.length) {
const targetIndex = highlightIndex >= 0 ? highlightIndex : 0;
addSuggestedTag(filtered[targetIndex]);
return;
}
commitTags(value);
}
}
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
const text = event.clipboardData.getData("text");
if (!text) return;
if (!/[\n,]/.test(text)) return;
event.preventDefault();
commitTags(text);
}
function handleBlur() {
if (ignoreBlurRef.current) {
ignoreBlurRef.current = false;
return;
}
commitTags(value);
}
return (
<label className="text-sm text-muted">
<span className="flex items-center justify-between gap-2">
{/* <span>{label}</span> */}
{labelAction ? <span className="flex items-center gap-2">{labelAction}</span> : null}
</span>
<div className="relative mt-1">
<div className={`rounded-lg border ${invalid ? "border-red-400/70" : "border-accent-weak"} bg-panel p-2`}>
<div className="flex flex-wrap gap-2">
{false && !chipsBelow ? tags.map(tag => {
const isRemoved = removedSet.has(tag.toLowerCase());
const isHighlighted = highlightSet.has(tag.toLowerCase());
return (
<button
key={tag}
type="button"
onClick={() => onToggleTag?.(tag)}
className={`rounded-full border px-2 py-0.5 text-xs transition ${isRemoved ? "border-red-400/60 text-red-200 bg-red-500/10" : isHighlighted ? "border-2 border-[color:var(--color-accent)] text-[color:var(--color-text)] bg-accent-soft" : "border-accent-weak text-[color:var(--color-text)] bg-accent-soft hover:border-accent"}`}
>
#{tag}
</button>
);
}) : null}
<input
ref={setInputRef}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
placeholder={"Add tags... (who, where, why, etc.)"}
className="min-w-[140px] flex-1 bg-transparent text-sm text-[color:var(--color-text)] outline-none"
/>
</div>
</div>
{true || chipsBelow ? (
<div className="mt-2 flex flex-wrap gap-2">
{tags.map(tag => {
const isRemoved = removedSet.has(tag.toLowerCase());
const isHighlighted = highlightSet.has(tag.toLowerCase());
return (
<button
key={tag}
type="button"
onClick={() => onToggleTag?.(tag)}
className={`rounded-full border px-2 py-0.5 text-xs transition ${isRemoved ? "border-red-400/60 text-red-200 bg-red-500/10" : isHighlighted ? "border-2 border-[color:var(--color-accent)] text-[color:var(--color-text)] bg-accent-soft" : "border-accent-weak text-[color:var(--color-text)] bg-accent-soft hover:border-accent"}`}
>
#{tag}
</button>
);
})}
</div>
) : null}
{filtered.length ? (
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border border-accent-weak bg-panel shadow-lg">
{filtered.map((tag, index) => (
<button
key={tag}
type="button"
className={`flex w-full items-center justify-between px-2 py-1 text-left text-xs text-muted ${index === highlightIndex ? "bg-accent-soft" : "hover:bg-accent-soft"}`}
onMouseEnter={() => {
setDesiredIndex(index);
setHighlightIndex(index);
}}
onMouseDown={event => {
event.preventDefault();
ignoreBlurRef.current = true;
addSuggestedTag(tag);
internalInputRef.current?.focus();
}}
onClick={event => {
event.preventDefault();
}}
>
<span>#{tag}</span>
<span className="text-[10px] text-soft">Use tag</span>
</button>
))}
</div>
) : showEmptySuggestion ? (
<div className="absolute left-0 right-0 top-full z-20 mt-2 rounded-lg border-2 border-yellow-400 bg-panel shadow-lg">
<button
type="button"
className="flex w-full items-center justify-between px-2 py-2 text-left text-sm text-yellow-200 hover:bg-accent-soft disabled:cursor-not-allowed disabled:opacity-60"
onMouseDown={event => {
event.preventDefault();
ignoreBlurRef.current = true;
}}
onClick={() => {
onEmptySuggestionClick?.();
internalInputRef.current?.focus();
}}
disabled={emptySuggestionDisabled}
>
<span className="flex items-center gap-2">
<span aria-hidden></span>
{emptySuggestionLabel}
</span>
<span className="text-xs text-yellow-200/80">Go</span>
</button>
</div>
) : null}
</div>
</label>
);
}

21
apps/web/e2e/auth.spec.ts Normal file
View File

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { login } from "./test-helpers";
test("login and register hide navbar", async ({ page }) => {
await page.goto("/login");
await expect(page.locator("header")).toHaveCount(0);
await page.goto("/register");
await expect(page.locator("header")).toHaveCount(0);
});
test("login shows entries for seeded owner", async ({ page }) => {
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
});
test("no-group user sees empty state", async ({ page }) => {
await login(page, "nogroup@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await expect(page.getByText("Create or join a group to add entries.")).toBeVisible();
});

View File

@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { login } from "./test-helpers";
test("group dropdown lists seeded groups", async ({ page }) => {
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
const dropdown = page.getByRole("button", { name: /Group:/ });
await dropdown.click();
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
await expect(page.getByRole("button", { name: "Beta Office GROUP_OWNER" })).toBeVisible();
await expect(page.getByRole("button", { name: "Gamma Club MEMBER" })).toBeVisible();
});
test("group settings show join requests and policy", async ({ page }) => {
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await page.getByRole("button", { name: "Group settings" }).click();
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
await expect(page.getByText("Join requests")).toBeVisible();
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
const approvalButton = page.getByRole("button", { name: "Manual" });
await expect(approvalButton).toHaveAttribute("aria-pressed", "true");
});

View File

@ -0,0 +1,6 @@
import { test, expect } from "@playwright/test";
test("home redirects to login when unauthenticated", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});

View File

@ -0,0 +1,54 @@
import { test, expect } from "@playwright/test";
import { login } from "./test-helpers";
test("seeded entries render with tags and no-tag state", async ({ page }) => {
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
await expect(page.getByText("$12.50")).toBeVisible();
await expect(page.getByText("#Food")).toBeVisible();
await expect(page.getByText("#Travel")).toBeVisible();
await expect(page.getByText("No tags")).toBeVisible();
});
test("entry details modal opens", async ({ page }) => {
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await page.getByText("$12.50").click();
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
});
test("empty tag callout shows contact admin for members", async ({ page }) => {
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
const dropdown = page.getByRole("button", { name: /Group:/ });
await dropdown.click();
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
await page.getByRole("button", { name: /Gamma Club/ }).click();
await waitSetActive;
await page.getByRole("button", { name: "Add entry" }).click();
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
await expect(callout).toBeVisible();
await expect(callout).toBeDisabled();
});
test("empty tag callout navigates to settings for admins", async ({ page }) => {
await login(page, "member1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
const dropdown = page.getByRole("button", { name: /Group:/ });
await dropdown.click();
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
await page.getByRole("button", { name: /Gamma Club/ }).click();
await waitSetActive;
await page.getByRole("button", { name: "Add entry" }).click();
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
});

View File

@ -0,0 +1,13 @@
import type { Page } from "@playwright/test";
export async function login(page: Page, email: string, password: string) {
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/");
const waitGroups = page.waitForResponse(res => res.url().includes("/api/groups") && res.request().method() === "GET");
const waitActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "GET");
await page.reload();
await Promise.all([waitGroups, waitActive]);
}

View File

@ -0,0 +1,21 @@
"use client";
import { createContext, useContext } from "react";
import useAuth from "@/hooks/use-auth";
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
type AuthProviderProps = {
children: React.ReactNode;
};
export function AuthProvider({ children }: AuthProviderProps) {
const auth = useAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
export function useAuthContext() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("AuthProvider is missing");
return ctx;
}

View File

@ -0,0 +1,30 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
type EntryMutationContextValue = {
mutationVersion: number;
notifyEntryMutation: () => void;
};
const EntryMutationContext = createContext<EntryMutationContextValue | null>(null);
export function EntryMutationProvider({ children }: { children: ReactNode }) {
const [mutationVersion, setMutationVersion] = useState(0);
const notifyEntryMutation = useCallback(() => {
setMutationVersion(prev => prev + 1);
}, []);
return (
<EntryMutationContext.Provider value={{ mutationVersion, notifyEntryMutation }}>
{children}
</EntryMutationContext.Provider>
);
}
export function useEntryMutation() {
const context = useContext(EntryMutationContext);
if (!context) throw new Error("useEntryMutation must be used within EntryMutationProvider");
return context;
}

View File

@ -0,0 +1,21 @@
"use client";
import { createContext, useContext } from "react";
import useGroups from "@/hooks/use-groups";
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
type GroupsProviderProps = {
children: React.ReactNode;
};
export function GroupsProvider({ children }: GroupsProviderProps) {
const groups = useGroups();
return <GroupsContext.Provider value={groups}>{children}</GroupsContext.Provider>;
}
export function useGroupsContext() {
const ctx = useContext(GroupsContext);
if (!ctx) throw new Error("GroupsProvider is missing");
return ctx;
}

View File

@ -0,0 +1,83 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/components/notifications-toaster";
type NotifyInput = {
title: string;
message?: string;
tone?: NotificationTone;
durationMs?: number;
};
type NotificationsContextValue = {
notify: (input: NotifyInput) => void;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<NotificationItem[]>([]);
const timersRef = useRef<number[]>([]);
function dismiss(id: string) {
setItems(prev => prev.map(item => item.id === id ? { ...item, closing: true } : item));
const t = window.setTimeout(() => {
setItems(prev => prev.filter(item => item.id !== id));
}, 250);
timersRef.current.push(t);
}
function notify(input: NotifyInput) {
const id = createId();
const durationMs = Math.max(1200, input.durationMs ?? 4200);
const tone = input.tone ?? "info";
setItems(prev => [
...prev,
{
id,
title: input.title,
message: input.message,
tone,
closing: false
}
]);
const fadeMs = 250;
const t1 = window.setTimeout(() => {
setItems(prev => prev.map(item => item.id === id ? { ...item, closing: true } : item));
}, Math.max(0, durationMs - fadeMs));
const t2 = window.setTimeout(() => {
setItems(prev => prev.filter(item => item.id !== id));
}, durationMs);
timersRef.current.push(t1, t2);
}
useEffect(() => {
return () => {
timersRef.current.forEach(timer => window.clearTimeout(timer));
timersRef.current = [];
};
}, []);
const value = useMemo(() => ({ notify }), []);
return (
<NotificationsContext.Provider value={value}>
{children}
<NotificationsToaster items={items} onDismiss={dismiss} />
</NotificationsContext.Provider>
);
}
export function useNotificationsContext() {
const ctx = useContext(NotificationsContext);
if (!ctx) throw new Error("NotificationsProvider is missing");
return ctx;
}

View File

@ -0,0 +1,72 @@
import { useCallback, useState } from "react";
import { authLogin, authMe, authRegister, authLogout } from "@/lib/client/auth";
import type { User } from "@/lib/shared/types";
import type { ApiResult } from "@/lib/client/fetch-json";
type LoginInput = {
email: string;
password: string;
remember: boolean;
};
type RegisterInput = {
email: string;
password: string;
displayName?: string;
};
export default function useAuth() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const checkSession = useCallback(async () => {
const res = await authMe();
if (isError(res)) return null;
return res.data.user as User;
}, []);
const login = useCallback(async (input: LoginInput) => {
setError("");
setLoading(true);
try {
const result = await authLogin(input);
if (isError(result)) {
setError(result.error.message || "");
return { ok: false, error: result.error.message || "" };
}
return { ok: true } as { ok: true };
} finally {
setLoading(false);
}
}, []);
const register = useCallback(async (input: RegisterInput) => {
setError("");
setLoading(true);
try {
const result = await authRegister(input);
if (isError(result)) {
setError(result.error.message || "");
return { ok: false, error: result.error.message || "" };
}
return { ok: true } as { ok: true };
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
const result = await authLogout();
if (isError(result)) {
setError(result.error.message || "");
return { ok: false, error: result.error.message || "" };
}
return { ok: true } as { ok: true };
}, []);
return { checkSession, login, register, logout, error, loading };
}

View File

@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react";
import { bucketsCreate, bucketsDelete, bucketsList, bucketsUpdate } from "@/lib/client/buckets";
import type { Bucket } from "@/lib/client/buckets";
import type { ApiResult } from "@/lib/client/fetch-json";
type CreateBucketInput = {
name: string;
description?: string;
iconKey?: string | null;
budgetLimitDollars?: number | null;
position?: number;
tags?: string[];
necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY";
windowDays?: number;
};
type UpdateBucketInput = CreateBucketInput & { id: number };
export default function useBuckets(activeGroupId?: number | null) {
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const load = useCallback(async () => {
if (!activeGroupId) {
setError("");
setBuckets([]);
setLoading(false);
return;
}
setLoading(true);
setError("");
const result = await bucketsList();
if (isError(result)) {
setError(result.error.message || "");
setBuckets([]);
} else {
setBuckets(result.data.buckets || []);
}
setLoading(false);
}, [activeGroupId]);
const createBucket = useCallback(async (input: CreateBucketInput) => {
setError("");
const result = await bucketsCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const updateBucket = useCallback(async (input: UpdateBucketInput) => {
setError("");
const result = await bucketsUpdate(input);
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const reorderBuckets = useCallback(async (ordered: Bucket[]) => {
setError("");
const updates = ordered.map((bucket, index) => bucketsUpdate({
id: bucket.id,
name: bucket.name,
description: bucket.description || undefined,
iconKey: bucket.iconKey,
budgetLimitDollars: bucket.budgetLimitDollars,
position: index,
tags: bucket.tags,
necessity: bucket.necessity,
windowDays: bucket.windowDays
}));
const results = await Promise.all(updates);
const failed = results.find(result => isError(result));
if (failed && "error" in failed) {
setError(failed.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const deleteBucket = useCallback(async (id: number | string) => {
setError("");
const result = await bucketsDelete({ id });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
useEffect(() => {
load();
}, [load]);
return {
buckets,
loading,
error,
createBucket,
updateBucket,
reorderBuckets,
deleteBucket,
reload: load
};
}

View File

@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import type { Entry } from "@/lib/shared/types";
import { entriesCreate, entriesDelete, entriesList, entriesUpdate } from "@/lib/client/entries";
import type { ApiResult } from "@/lib/client/fetch-json";
type CreateEntryInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
bucketId?: number | null;
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
};
type UpdateEntryInput = CreateEntryInput & { id: number };
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
function compareEntriesDesc(a: Entry, b: Entry) {
if (a.occurredAt === b.occurredAt) return Number(b.id) - Number(a.id);
return a.occurredAt > b.occurredAt ? -1 : 1;
}
function upsertEntrySorted(entries: Entry[], next: Entry) {
const without = entries.filter(entry => Number(entry.id) !== Number(next.id));
const merged = [next, ...without];
merged.sort(compareEntriesDesc);
return merged;
}
export default function useEntries(activeGroupId?: number | null) {
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!activeGroupId) {
setError("");
setEntries([]);
setLoading(false);
return;
}
setLoading(true);
setError("");
const result = await entriesList();
if (isError(result)) {
setError(result.error.message || "");
setEntries([]);
} else {
setEntries(result.data.entries || []);
}
setLoading(false);
}, [activeGroupId]);
const createEntry = useCallback(async (input: CreateEntryInput) => {
setError("");
const result = await entriesCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return null;
}
const created = result.data.entry;
setEntries(prev => upsertEntrySorted(prev, created));
return created;
}, []);
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
setError("");
const result = await entriesUpdate(input);
if (isError(result)) {
setError(result.error.message || "");
return null;
}
const updated = result.data.entry;
setEntries(prev => {
return upsertEntrySorted(prev, updated);
});
return updated;
}, []);
const deleteEntry = useCallback(async (id: number | string) => {
setError("");
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) return null;
let removed: Entry | null = null;
const result = await entriesDelete({ id });
if (isError(result)) {
setError(result.error.message || "");
return null;
}
setEntries(prev => {
const index = prev.findIndex(entry => Number(entry.id) === numericId);
if (index < 0) return prev;
removed = prev[index];
return [...prev.slice(0, index), ...prev.slice(index + 1)];
});
return removed;
}, []);
useEffect(() => {
load();
}, [load]);
return {
entries,
loading,
error,
createEntry,
updateEntry,
deleteEntry,
reload: load
};
}

View File

@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiResult } from "@/lib/client/fetch-json";
import { groupAuditList, type GroupAuditEvent } from "@/lib/client/group-audit";
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
export default function useGroupAudit(activeGroupId?: number | null) {
const [events, setEvents] = useState<GroupAuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!activeGroupId) {
setEvents([]);
setError("");
return;
}
setLoading(true);
setError("");
const result = await groupAuditList();
if (isError(result)) setError(result.error.message || "");
else setEvents(result.data.events || []);
setLoading(false);
}, [activeGroupId]);
useEffect(() => {
load();
}, [load]);
return { events, loading, error, reload: load };
}

View File

@ -0,0 +1,82 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiResult } from "@/lib/client/fetch-json";
import { groupInvitesCreate, groupInvitesDelete, groupInvitesList, groupInvitesRevoke, groupInvitesRevive, type InviteLink, type JoinPolicy } from "@/lib/client/group-invites";
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
export default function useGroupInvites(activeGroupId?: number | null) {
const [links, setLinks] = useState<InviteLink[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!activeGroupId) {
setLinks([]);
setError("");
return;
}
setLoading(true);
setError("");
const result = await groupInvitesList();
if (isError(result)) setError(result.error.message || "");
else setLinks(result.data.links || []);
setLoading(false);
}, [activeGroupId]);
const create = useCallback(async (input: { policy: JoinPolicy; singleUse: boolean; ttlDays: number }) => {
const result = await groupInvitesCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return null;
}
await load();
return result.data.link;
}, [load]);
const revoke = useCallback(async (linkId: number) => {
const result = await groupInvitesRevoke({ linkId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const revive = useCallback(async (linkId: number, ttlDays: number) => {
const result = await groupInvitesRevive({ linkId, ttlDays });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const remove = useCallback(async (linkId: number) => {
const result = await groupInvitesDelete({ linkId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
useEffect(() => {
load();
}, [load]);
return {
links,
loading,
error,
create,
revoke,
revive,
remove,
reload: load
};
}

View File

@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiResult } from "@/lib/client/fetch-json";
import {
groupMembersApprove,
groupMembersDemote,
groupMembersKick,
groupMembersLeave,
groupMembersList,
groupMembersPromote,
groupMembersTransferOwner,
groupMembersDeny,
type GroupMember,
type JoinRequest
} from "@/lib/client/group-members";
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
export default function useGroupMembers(activeGroupId?: number | null) {
const [members, setMembers] = useState<GroupMember[]>([]);
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!activeGroupId) {
setMembers([]);
setRequests([]);
setCurrentUserId(null);
setError("");
return;
}
setLoading(true);
setError("");
const result = await groupMembersList();
if (isError(result)) {
setError(result.error.message || "");
} else {
setMembers(result.data.members || []);
setRequests(result.data.requests || []);
setCurrentUserId(result.data.currentUserId || null);
}
setLoading(false);
}, [activeGroupId]);
const approve = useCallback(async (userId: number, requestId?: number) => {
const result = await groupMembersApprove({ userId, requestId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const deny = useCallback(async (userId: number) => {
const result = await groupMembersDeny({ userId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const kick = useCallback(async (userId: number) => {
const result = await groupMembersKick({ userId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const promote = useCallback(async (userId: number) => {
const result = await groupMembersPromote({ userId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const demote = useCallback(async (userId: number) => {
const result = await groupMembersDemote({ userId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const transferOwner = useCallback(async (userId: number) => {
const result = await groupMembersTransferOwner({ userId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const leave = useCallback(async () => {
const result = await groupMembersLeave();
if (isError(result)) {
setError(result.error.message || "");
return false;
}
return true;
}, []);
useEffect(() => {
load();
}, [load]);
return {
members,
requests,
currentUserId,
loading,
error,
approve,
deny,
kick,
promote,
demote,
transferOwner,
leave,
reload: load
};
}

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
import { groupSettingsGet, groupSettingsUpdate } from "@/lib/client/group-settings";
import type { ApiResult } from "@/lib/client/fetch-json";
export default function useGroupSettings(activeGroupId?: number | null) {
const [settings, setSettings] = useState({ allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const load = useCallback(async () => {
if (!activeGroupId) {
setSettings({ allowMemberTagManage: false, joinPolicy: "NOT_ACCEPTING" });
setError("");
return;
}
setLoading(true);
setError("");
const result = await groupSettingsGet();
if (isError(result)) setError(result.error.message || "");
else setSettings(result.data.settings);
setLoading(false);
}, [activeGroupId]);
const updateSettings = useCallback(async (allowMemberTagManage: boolean, joinPolicy?: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") => {
setLoading(true);
setError("");
const result = await groupSettingsUpdate({ allowMemberTagManage, joinPolicy });
if (isError(result)) setError(result.error.message || "");
else setSettings(result.data.settings);
setLoading(false);
return isError(result) ? false : true;
}, []);
useEffect(() => {
load();
}, [load]);
return { settings, loading, error, updateSettings, reload: load };
}

View File

@ -0,0 +1,100 @@
import { useCallback, useEffect, useState } from "react";
import type { Group } from "@/lib/shared/types";
import { groupsActive, groupsCreate, groupsJoin, groupsList, groupsSetActive } from "@/lib/client/groups";
import type { ApiResult } from "@/lib/client/fetch-json";
type CreateGroupInput = {
name: string;
};
type JoinGroupInput = {
inviteCode: string;
};
export default function useGroups() {
const [groups, setGroups] = useState<Group[]>([]);
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const load = useCallback(async () => {
setError("");
setLoading(true);
try {
const res = await groupsList();
if (!isError(res)) setGroups(res.data.groups || []);
const activeRes = await groupsActive();
if (!isError(activeRes)) setActiveGroupId(activeRes.data.active?.id || null);
} finally {
setLoading(false);
}
}, []);
const createGroup = useCallback(async (input: CreateGroupInput) => {
setError("");
setLoading(true);
try {
const result = await groupsCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return null as { id: number; name: string; inviteCode?: string } | null;
}
const group = result.data.group as { id: number; name: string; inviteCode?: string };
setGroups(prev => [{ id: group.id, name: group.name, role: "GROUP_ADMIN" }, ...prev]);
setActiveGroupId(group.id);
await setActiveGroup(group.id);
return group;
} finally {
setLoading(false);
}
}, []);
const joinGroup = useCallback(async (input: JoinGroupInput) => {
setError("");
setLoading(true);
try {
const result = await groupsJoin(input);
if (isError(result)) {
setError(result.error.message || "");
return null as { id: number; name: string } | null;
}
const group = result.data.group as { id: number; name: string };
setGroups(prev => prev.some(g => g.id === group.id) ? prev : [{ id: group.id, name: group.name, role: "MEMBER" }, ...prev]);
setActiveGroupId(group.id);
await setActiveGroup(group.id);
return group;
} finally {
setLoading(false);
}
}, []);
const setActiveGroup = useCallback(async (groupId: number) => {
setError("");
const result = await groupsSetActive({ groupId });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
setActiveGroupId(groupId);
return true;
}, []);
useEffect(() => {
load();
}, [load]);
return {
groups,
activeGroupId,
loading,
error,
createGroup,
joinGroup,
setActiveGroup,
reload: load
};
}

View File

@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiResult } from "@/lib/client/fetch-json";
import { inviteLinkAccept, inviteLinkGet, type InviteAcceptResult, type InviteLinkSummary } from "@/lib/client/invite-links";
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
export default function useInviteLink(token: string | null) {
const [link, setLink] = useState<InviteLinkSummary | null>(null);
const [loading, setLoading] = useState(false);
const [accepting, setAccepting] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<InviteAcceptResult | null>(null);
const load = useCallback(async () => {
if (!token) {
setLink(null);
setError("");
setResult(null);
return;
}
setLoading(true);
setError("");
setResult(null);
const response = await inviteLinkGet(token);
if (isError(response)) {
setError(response.error.message || "");
setLink(null);
} else {
setLink(response.data.link);
}
setLoading(false);
}, [token]);
const accept = useCallback(async () => {
if (!token) return null;
setAccepting(true);
setError("");
const response = await inviteLinkAccept(token);
if (isError(response)) {
setError(response.error.message || "");
setAccepting(false);
return null;
}
setResult(response.data.result);
setAccepting(false);
return response.data.result;
}, [token]);
useEffect(() => {
load();
}, [load]);
return {
link,
loading,
accepting,
error,
result,
accept,
reload: load
};
}

View File

@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from "react";
import type { Entry } from "@/lib/shared/types";
import { recurringEntriesCreate, recurringEntriesDelete, recurringEntriesList, recurringEntriesUpdate } from "@/lib/client/recurring-entries";
import type { ApiResult } from "@/lib/client/fetch-json";
type CreateRecurringEntryInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
bucketId?: number | null;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
};
type UpdateRecurringEntryInput = CreateRecurringEntryInput & { id: number };
export default function useRecurringEntries(activeGroupId?: number | null) {
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const load = useCallback(async () => {
if (!activeGroupId) {
setError("");
setEntries([]);
setLoading(false);
return;
}
setLoading(true);
setError("");
const result = await recurringEntriesList();
if (isError(result)) {
setError(result.error.message || "");
setEntries([]);
} else {
setEntries(result.data.entries || []);
}
setLoading(false);
}, [activeGroupId]);
const createEntry = useCallback(async (input: CreateRecurringEntryInput) => {
setError("");
const result = await recurringEntriesCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const updateEntry = useCallback(async (input: UpdateRecurringEntryInput) => {
setError("");
const result = await recurringEntriesUpdate(input);
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const deleteEntry = useCallback(async (id: number | string) => {
setError("");
const result = await recurringEntriesDelete({ id });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
useEffect(() => {
load();
}, [load]);
return {
entries,
loading,
error,
createEntry,
updateEntry,
deleteEntry,
reload: load
};
}

View File

@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from "react";
import { tagsCreate, tagsDelete, tagsList } from "@/lib/client/tags";
import type { ApiResult } from "@/lib/client/fetch-json";
export default function useTags(activeGroupId?: number | null) {
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const load = useCallback(async () => {
if (!activeGroupId) {
setTags([]);
setError("");
return;
}
setLoading(true);
setError("");
const result = await tagsList();
if (isError(result)) {
setError(result.error.message || "");
setTags([]);
} else {
setTags(result.data.tags || []);
}
setLoading(false);
}, [activeGroupId]);
const addTags = useCallback(async (nextTags: string[]) => {
setError("");
const result = await tagsCreate({ tags: nextTags });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
const removeTag = useCallback(async (tag: string) => {
setError("");
const result = await tagsDelete({ name: tag });
if (isError(result)) {
setError(result.error.message || "");
return false;
}
await load();
return true;
}, [load]);
useEffect(() => {
load();
}, [load]);
return { tags, loading, error, addTags, removeTag, reload: load };
}

1
apps/web/lib/auth.ts Normal file
View File

@ -0,0 +1 @@
export { };

Some files were not shown because too many files have changed in this diff Show More