Compare commits

..

No commits in common. "f8e426542d1f7a912da4ed93ac29f1ea595e2899" and "4873449e16f96859caefc2f76223d0457116b755" have entirely different histories.

180 changed files with 2313 additions and 7141 deletions

View File

@ -1,28 +0,0 @@
model = "gpt-5.3-codex"
model_reasoning_effort = "high"
approval_policy = "never" # values: untrusted | on-failure | on-request | never
sandbox_mode = "workspace-write"
developer_instructions = """
Work in phases.
- At the start of each phase: state the goal + plan briefly.
- During the phase: edit files and run commands as needed.
- End of each phase: summarize what changed, show key diffs/paths touched, and stop for review.
Do not proceed to the next phase until the user says "continue".
""" :contentReference[oaicite:3]{index=3}
[sandbox_workspace_write]
# Keep network off (commands that need internet will fail instead of prompting).
network_access = false :contentReference[oaicite:4]{index=4}
# Tighten writes to be “workspace only” by removing temp-dir write roots.
# (Workspace-write normally includes temp dirs; these reduce that surface area.)
exclude_slash_tmp = true
exclude_tmpdir_env_var = true :contentReference[oaicite:5]{index=5}
[projects.'C:\Users\Nico\Desktop\Projects\fiddy-finance-buddy-app']
trust_level = "trusted"
[features]
elevated_windows_sandbox = true

View File

@ -1,93 +0,0 @@
name: Build & Deploy Fiddy (Dokploy)
on:
push:
branches: [ "main" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/fiddy
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:main -f docker/Dockerfile .
- name: Build Scheduler Image
run: |
docker build -t $REGISTRY/scheduler:${{ github.sha }} -t $REGISTRY/scheduler:main -f docker/Dockerfile.scheduler .
- name: Push Web Image
run: |
docker push $REGISTRY/web:${{ github.sha }}
docker push $REGISTRY/web:main
- name: Push Scheduler Image
run: |
docker push $REGISTRY/scheduler:${{ github.sha }}
docker push $REGISTRY/scheduler:main
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Trigger Dokploy Deploy
env:
DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }}
IMAGE_TAG: ${{ github.sha }}
run: |
if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then
echo "Missing DOKPLOY_DEPLOY_HOOK secret"
exit 1
fi
curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \
-H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
- name: Trigger Dokploy Scheduler Deploy
env:
DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }}
IMAGE_TAG: ${{ github.sha }}
run: |
if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then
echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger"
exit 0
fi
curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \
-H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
- name: Wait for Ready Health Check
env:
HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }}
MAX_ATTEMPTS: "30"
SLEEP_SECONDS: "10"
run: |
if [ -z "$HEALTH_URL" ]; then
echo "Missing DOKPLOY_HEALTHCHECK_URL secret"
exit 1
fi
bash scripts/wait-for-health.sh

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

View File

@ -1,41 +1,123 @@
# Copilot Instructions — Fiddy (External DB)
## Authority
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
- Keep this file short: its a guide for Copilot behavior, not the full spec.
## 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.
## High-level behavior
- Make the **smallest change** that resolves the bug or request.
- **Scan the repo first** for existing patterns (dont invent files/endpoints unless necessary).
- Respect layering: **route → server service → client wrapper → hook → UI**.
- Keep diffs tight; avoid large refactors unless required.
## 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
## Hard rules (do not violate)
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
- No cron/worker jobs.
- Server-side RBAC only; client checks are UX only.
- Never log secrets, receipt bytes, or full invite codes (invite codes = **last4 only**).
- Entries list endpoints must never return receipt bytes.
## Environment
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
- `DATABASE_URL` points to the external DB server (NOT a container).
## Architecture quick map (follow existing patterns)
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
## Auth
- Custom email/password auth.
- Use HttpOnly session cookies backed by DB table `sessions`.
- NEVER trust client-side RBAC checks.
## API conventions
- Prefer error shape: `{ error: { code, message }, request_id? }`
- Validate input at the route boundary; authorize in services.
## 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.
## Next.js dynamic route params (required)
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
- `const { id } = await context.params;`
## Tests
- When changing API behavior, add/update tests.
- Prefer including negative cases: unauthorized / not-a-member / invalid input.
## UI expectations
## UI
- Dark mode, minimal, mobile-first.
- Navbar layout: left nav dropdown, middle group selector, right user menu.
- 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.

View File

@ -1,53 +0,0 @@
# AGENTS.md — Fiddy (External DB)
## Authority
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
- Do not implement features unless required to fix the bug.
## Non-negotiables
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
- Dev/Prod share schema via migrations in `packages/db/migrations`.
- No cron/worker jobs. Fixes must work without background tasks.
- Server-side RBAC only. Client checks are UX only.
## Security / logging (hard rules)
- Never log secrets (passwords/tokens/cookies).
- Never log receipt bytes.
- Never log full invite codes; logs/audit store last4 only.
## Non-regression contracts
- Sessions are DB-backed (`sessions` table) and cookies are HttpOnly.
- Receipt images stored in `receipts` (`bytea`).
- Entries list endpoints must NEVER return receipt bytes.
- API responses must include `request_id`; audit logs must include `request_id`.
## Architecture boundaries (follow existing patterns; dont invent)
1) API routes: `app/api/**/route.ts`
- Thin: parse/validate + call service, return JSON.
2) Server services: `lib/server/*`
- Own DB + authz. Must include `import "server-only";`.
3) Client wrappers: `lib/client/*`
- Typed fetch + error normalization; always send credentials.
4) Hooks: `hooks/use-*.ts`
- Primary UI-facing API layer; components avoid raw `fetch()`.
## Next.js dynamic route params (required)
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
- `const { id } = await context.params;`
## Working style
- Scan repo first; dont guess file names or patterns.
- Make the smallest change that resolves the issue.
- Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake).
## Response icon legend
Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)":
- `🔄` in progress
- `✅` completed
- `🧪` verification/test result
- `⚠️` risk/blocker/manual action
- `❌` failure
- `🧭` recommendation/next step

View File

@ -1,130 +1,24 @@
# Project Instructions — Fiddy (External DB)
## 1) Core expectation
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
## Core expectation
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
## 2) Authority & doc order
1) **PROJECT_INSTRUCTIONS.md** (this file) is the source of truth.
2) **DEBUGGING_INSTRUCTIONS.md** (repo root) is required for bugfix work.
3) Other instruction files (e.g. `.github/copilot-instructions.md`) must not conflict with this doc.
If anything conflicts, follow **this** doc.
---
## 3) Non-negotiables (hard rules)
### External DB + migrations
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
### No background jobs
- **No cron/worker jobs**. Any fix must work without background tasks.
### Security / logging
- **Never log secrets** (passwords, tokens, session cookies).
- **Never log receipt bytes**.
- **Never log full invite codes** — logs/audit store **last4 only**.
### Server-side authorization only
- **Server-side RBAC only.** Client checks are UX only and must not be trusted.
---
## 4) Non-regression contracts (do not break)
### Auth
- Custom email/password auth.
- Sessions are **DB-backed** and stored in table `sessions`.
- Session cookies are **HttpOnly**.
### Receipts
- Receipt images are stored in Postgres `bytea` table `receipts`.
- **Entries list endpoints must never return receipt image bytes.**
- Receipt bytes are fetched only via a **separate endpoint** when inspecting a single item.
### Request IDs + audit
- API must generate a **`request_id`** and return it in responses.
- Audit logs must include `request_id`.
- Audit logs must never store full invite codes (store **last4 only**).
---
## 5) Architecture contract (Backend ↔ Client ↔ Hooks ↔ UI)
### No-assumptions rule (required)
Before making structural changes, first scan the repo and identify:
- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live
- existing API routes and helpers
- patterns already in use
Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**.
### Single mechanism rule (required)
For any cross-component state propagation concern, keep **one** canonical mechanism only:
- Context **OR** custom events **OR** cache invalidation
Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR.
### Layering (hard boundaries)
For every domain (auth, groups, entries, receipts, etc.) follow this flow:
1) **API Route Handlers**`app/api/.../route.ts`
- Thin: parse/validate input, call a server service, return JSON.
- No direct DB queries in route files unless there is no existing server service.
2) **Server Services (DB + authorization)**`lib/server/*`
- Own all DB access and authorization helpers.
- 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/receipts.ts`, `lib/server/session.ts`.
3) **Client API Wrappers**`lib/client/*`
- Typed fetch helpers only (no React state).
- Centralize fetch + 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 there is a strong reason.
### API conventions
- Prefer consistent JSON error shape:
- `{ error: { code: string, message: string }, request_id?: string }`
- Validate inputs at the route boundary (shape/type), authorize in server services.
- Mirror existing REST style used in the project.
### Next.js route params checklist (required)
For `app/api/**/[param]/route.ts`:
- Treat `context.params` as **async** and `await` it before reading properties.
- Example: `const { id } = await context.params;`
### Frontend structure preference
- Prefer domain-first structure: `features/<domain>/...` + `shared/...`.
- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated).
### Maintainability thresholds (refactor triggers)
- Component files > **400 lines** should be split into container/presentational parts.
- Hook files > **150 lines** should extract helper functions/services.
- Functions with more than **3 nested branches** should be extracted.
---
## 6) Decisions / constraints (Group Settings)
## 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.
- 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).
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
- 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).
---
## 7) Do first (vertical slice)
## Do first (vertical slice)
1) DB migrate command + schema
2) Register/Login/Logout (custom sessions)
3) Protected dashboard page
@ -133,57 +27,24 @@ For `app/api/**/[param]/route.ts`:
6) Receipt upload/download endpoints
7) Settings + Reports
---
## 8) Definition of done
- Works via `docker-compose.dev.yml` with external DB
## 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
- No orphaned utilities/hooks/contexts after refactors
- No duplicate mechanisms for the same state flow
- Text encoding remains clean in user-facing strings/docs
---
## 9) Desktop + mobile UX checklist (required)
## 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.
---
## 10) Tests (required)
- Add/update tests for API behavior changes (auth, groups, entries, receipts).
- Include negative cases where applicable:
- unauthorized
- not-a-member
- invalid input
---
## 11) Agent Response Legend (required)
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
Legend:
- `🔄` in progress
- `✅` completed
- `🧪` test/lint/verification result
- `📄` documentation update
- `🗄️` database or migration change
- `🚀` deploy/release step
- `⚠️` risk, blocker, or manual operator action needed
- `❌` failed command or unsuccessful attempt
- `` informational context
- `🧭` recommendation or next-step option
Usage rules:
- Include at least one status icon in each substantive agent response.
- Use one icon per bullet/line; avoid icon spam.
- Keep icon meaning consistent with this legend.
## PR review checklist
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
- No TypeScript warnings or lint errors introduced.

View File

@ -1,13 +0,0 @@
{
"name": "@fiddy/scheduler",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "tsx src/index.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
"pg": "^8.13.0"
}
}

View File

@ -1,168 +0,0 @@
import dotenv from "dotenv";
import pg from "pg";
dotenv.config();
type ScheduleRow = {
id: number;
group_id: number;
created_by: number;
entry_type: "SPENDING" | "INCOME";
amount_dollars: string | number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchase_type: string;
notes: string | null;
next_run_on: string;
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
interval_count: number;
end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
end_count: number | null;
end_date: string | null;
run_count: number;
};
const DEFAULT_POLL_MS = 60_000;
const DEFAULT_BATCH_SIZE = 100;
function getPollMs() {
const value = Number(process.env.SCHEDULER_POLL_MS || DEFAULT_POLL_MS);
if (!Number.isFinite(value) || value < 1_000) return DEFAULT_POLL_MS;
return Math.floor(value);
}
function getBatchSize() {
const value = Number(process.env.SCHEDULER_BATCH_SIZE || DEFAULT_BATCH_SIZE);
if (!Number.isFinite(value) || value < 1) return DEFAULT_BATCH_SIZE;
return Math.floor(value);
}
function addInterval(dateIso: string, frequency: ScheduleRow["frequency"], intervalCount: number) {
const safeInterval = Math.max(1, Number(intervalCount || 1));
const date = new Date(`${dateIso}T00:00:00Z`);
if (frequency === "DAILY") date.setUTCDate(date.getUTCDate() + safeInterval);
else if (frequency === "WEEKLY") date.setUTCDate(date.getUTCDate() + safeInterval * 7);
else if (frequency === "MONTHLY") date.setUTCMonth(date.getUTCMonth() + safeInterval);
else date.setUTCFullYear(date.getUTCFullYear() + safeInterval);
return date.toISOString().slice(0, 10);
}
function shouldDeactivate(input: { endCondition: ScheduleRow["end_condition"]; endCount: number | null; endDate: string | null; runCount: number; nextRunOn: string }) {
if (input.endCondition === "AFTER_COUNT" && input.endCount != null) {
return input.runCount >= input.endCount;
}
if (input.endCondition === "BY_DATE" && input.endDate) {
return input.nextRunOn > input.endDate;
}
return false;
}
async function runOnce(pool: pg.Pool) {
const client = await pool.connect();
const batchSize = getBatchSize();
let processed = 0;
try {
await client.query("begin");
const dueRows = (await client.query<ScheduleRow>(
`select id, group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes,
next_run_on, frequency, interval_count, end_condition, end_count, end_date, run_count
from schedules
where is_active=true and next_run_on <= (now() at time zone 'UTC')::date
order by next_run_on asc, id asc
for update skip locked
limit $1`,
[batchSize]
)).rows;
for (const row of dueRows) {
const runOn = row.next_run_on;
const entryInsert = await client.query<{ id: number }>(
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, source_schedule_id)
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
on conflict (source_schedule_id, occurred_at) do nothing
returning id`,
[
row.group_id,
row.created_by,
row.entry_type,
Number(row.amount_dollars),
runOn,
row.necessity,
row.purchase_type,
row.notes,
row.id
]
);
if (entryInsert.rows[0]?.id) {
await client.query(
`insert into entry_tags(entry_id, tag_id)
select $1, st.tag_id
from schedule_tags st
where st.schedule_id=$2
on conflict do nothing`,
[entryInsert.rows[0].id, row.id]
);
}
const nextRunOn = addInterval(runOn, row.frequency, row.interval_count);
const nextRunCount = Number(row.run_count || 0) + 1;
const deactivate = shouldDeactivate({
endCondition: row.end_condition,
endCount: row.end_count,
endDate: row.end_date,
runCount: nextRunCount,
nextRunOn
});
await client.query(
`update schedules
set next_run_on=$1,
last_run_on=$2,
run_count=$3,
is_active=$4,
updated_at=now()
where id=$5`,
[nextRunOn, runOn, nextRunCount, !deactivate, row.id]
);
processed += 1;
}
await client.query("commit");
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
return processed;
}
async function main() {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }
});
const pollMs = getPollMs();
console.log(`[scheduler] started, poll_ms=${pollMs}, batch_size=${getBatchSize()}`);
while (true) {
try {
const processed = await runOnce(pool);
if (processed > 0) console.log(`[scheduler] processed=${processed}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[scheduler] cycle_failed=${message}`);
}
await new Promise(resolve => setTimeout(resolve, pollMs));
}
}
main().catch(error => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[scheduler] fatal=${message}`);
process.exit(1);
});

View File

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View File

@ -5,10 +5,10 @@ import { calculateBucketUsage } from "../lib/shared/bucket-usage";
const today = "2026-02-11";
test("calculateBucketUsage matches tag subset", () => {
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH" as const, windowDays: 30 };
const bucket = { tags: ["groceries", "weekly"], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 20, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries", "weekly", "extra"], entryType: "SPENDING" as const },
{ amountDollars: 15, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["groceries"], entryType: "SPENDING" as const }
{ 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);
@ -16,10 +16,10 @@ test("calculateBucketUsage matches tag subset", () => {
assert.equal(result.matchedCount, 1);
});
test("calculateBucketUsage ignores non-spending entries", () => {
const bucket = { tags: ["rent"], necessity: "BOTH" as const, windowDays: 30 };
test("calculateBucketUsage excludes recurring entries", () => {
const bucket = { tags: ["rent"], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: ["rent"], entryType: "INCOME" as const }
{ amountDollars: 500, occurredAt: "2026-02-10", necessity: "NECESSARY", tags: ["rent"], isRecurring: true, entryType: "SPENDING" as const }
];
const result = calculateBucketUsage(bucket, entries, today);
@ -28,11 +28,11 @@ test("calculateBucketUsage ignores non-spending entries", () => {
});
test("calculateBucketUsage applies windowDays filtering", () => {
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 3 };
const bucket = { tags: [], necessity: "BOTH", windowDays: 3 };
const entries = [
{ amountDollars: 10, occurredAt: "2026-02-11", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-09", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-08", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const }
{ 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);
@ -41,10 +41,10 @@ test("calculateBucketUsage applies windowDays filtering", () => {
});
test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
const bucket = { tags: [], necessity: "BOTH" as const, windowDays: 30 };
const bucket = { tags: [], necessity: "BOTH", windowDays: 30 };
const entries = [
{ amountDollars: 12, occurredAt: "2026-02-10", necessity: "NECESSARY" as const, tags: [], entryType: "SPENDING" as const },
{ amountDollars: 8, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
{ 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);
@ -53,10 +53,10 @@ test("calculateBucketUsage accepts all when bucket necessity is BOTH", () => {
});
test("calculateBucketUsage halves BOTH entries when bucket not BOTH", () => {
const bucket = { tags: [], necessity: "NECESSARY" as const, windowDays: 30 };
const bucket = { tags: [], necessity: "NECESSARY", windowDays: 30 };
const entries = [
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "BOTH" as const, tags: [], entryType: "SPENDING" as const },
{ amountDollars: 10, occurredAt: "2026-02-10", necessity: "UNNECESSARY" as const, tags: [], entryType: "SPENDING" as const }
{ 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);

View File

@ -75,7 +75,7 @@ test("buckets CRUD", async t => {
assert.equal(updated?.name, "Groceries+");
assert.deepEqual(updated?.tags.sort(), ["groceries"]);
await deleteBucket({ id: bucket.id, groupId, userId });
await deleteBucket({ id: bucket.id, groupId });
const listAfter = await listBuckets(groupId);
assert.equal(listAfter.length, 0);
} finally {

View File

@ -1,14 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { ApiError, toErrorResponse } from "../lib/server/errors";
test("toErrorResponse includes request_id alias", async () => {
const prevDebug = process.env.DEBUG_API;
process.env.DEBUG_API = "0";
const { status, body } = toErrorResponse(new ApiError("UNAUTHORIZED"), "GET /api/example", "req_test_1");
assert.equal(status, 401);
assert.equal(body.requestId, "req_test_1");
assert.equal(body.request_id, "req_test_1");
assert.equal(body.error.code, "UNAUTHORIZED");
process.env.DEBUG_API = prevDebug;
});

View File

@ -104,7 +104,7 @@ test("group settings require admin", async t => {
);
await assert.rejects(
() => setGroupSettings({ userId: memberId!, groupId: groupId!, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
() => setGroupSettings({ userId: memberId!, groupId, allowMemberTagManage: true, joinPolicy: "AUTO_ACCEPT" }),
{ message: "FORBIDDEN" }
);

View File

@ -61,11 +61,11 @@ test("invite link auto-accept adds member", async t => {
const result = await acceptInviteLink({ userId: memberId!, token: link.token, requestId: "test-accept" });
assert.equal(result.status, "JOINED");
const queryResult = await client.query(
const { rowCount } = await client.query(
"select 1 from group_members where group_id=$1 and user_id=$2",
[groupId, memberId]
);
assert.equal(queryResult.rows.length, 1);
assert.equal(rowCount, 1);
} finally {
await cleanupTestData(client, { userIds: [ownerId, memberId], groupId });
client.release();

View File

@ -1,139 +0,0 @@
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 { ApiError } from "../lib/server/errors";
import { enforceAuthRateLimit, enforceIpRateLimit, enforceUserWriteRateLimit } from "../lib/server/rate-limit";
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);
async function ensureRateLimitTable() {
const pool = getPool();
await pool.query(`
create table if not exists rate_limits(
key text primary key,
window_start timestamptz not null,
count integer not null default 0,
updated_at timestamptz not null default now()
)
`);
}
test("auth rate limit blocks when threshold is exceeded", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
await ensureRateLimitTable();
const pool = getPool();
const marker = Date.now();
const ip = `test-ip-${marker}`;
const identifier = `rate_limit_${marker}@example.com`;
try {
await enforceAuthRateLimit({
route: "login",
ip,
identifier,
ipLimit: 1,
identifierLimit: 1,
windowMs: 60_000
});
await assert.rejects(
() => enforceAuthRateLimit({
route: "login",
ip,
identifier,
ipLimit: 1,
identifierLimit: 1,
windowMs: 60_000
}),
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
);
} finally {
await pool.query("delete from rate_limits where key like $1 or key like $2", [
`auth:login:ip:${ip}%`,
`auth:login:identifier:${identifier}%`
]);
}
});
test("user write rate limit blocks when threshold is exceeded", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
await ensureRateLimitTable();
const pool = getPool();
const userId = 987654;
const scope = `test_scope_${Date.now()}`;
try {
await enforceUserWriteRateLimit({
userId,
scope,
limit: 1,
windowMs: 60_000
});
await assert.rejects(
() => enforceUserWriteRateLimit({
userId,
scope,
limit: 1,
windowMs: 60_000
}),
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
);
} finally {
await pool.query("delete from rate_limits where key = $1", [
`write:user:${userId}:scope:${scope}`
]);
}
});
test("ip rate limit blocks when threshold is exceeded", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
await ensureRateLimitTable();
const pool = getPool();
const ip = `203.0.113.${Math.floor(Math.random() * 200)}`;
const scope = `test_ip_scope_${Date.now()}`;
const normalizedScope = scope.toLowerCase();
const normalizedIp = ip.toLowerCase();
try {
await enforceIpRateLimit({
scope,
ip,
limit: 1,
windowMs: 60_000
});
await assert.rejects(
() => enforceIpRateLimit({
scope,
ip,
limit: 1,
windowMs: 60_000
}),
(error: unknown) => error instanceof ApiError && error.code === "RATE_LIMITED"
);
} finally {
await pool.query("delete from rate_limits where key = $1", [
`ip:scope:${normalizedScope}:ip:${normalizedIp}`
]);
}
});

View File

@ -56,9 +56,11 @@ test("recurring entries list", async t => {
purchaseType: "Rent",
notes: "Monthly rent",
tags: ["rent"],
isRecurring: true,
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER"
endCondition: "NEVER",
nextRunAt: "2026-02-01"
});
const list = await listRecurringEntries(groupId);
@ -67,7 +69,7 @@ test("recurring entries list", async t => {
} finally {
if (groupId) {
const list = await listRecurringEntries(groupId);
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId, userId: userId! });
for (const entry of list) await deleteRecurringEntry({ id: entry.id, groupId });
}
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();

View File

@ -1,100 +0,0 @@
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 { createSchedule, deleteSchedule, listSchedules, updateSchedule } from "../lib/server/schedules";
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("schedules 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",
[`schedule_${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",
["Schedules Test", uniqueInviteCode("Q"), 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", "home"] });
const created = await createSchedule({
groupId,
userId,
entryType: "SPENDING",
amountDollars: 1200,
necessity: "NECESSARY",
purchaseType: "Rent",
notes: "Monthly rent",
tags: ["rent", "home"],
startsOn: "2026-03-01",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
createEntryNow: false
});
const listed = await listSchedules(groupId);
assert.equal(listed.length, 1);
assert.equal(listed[0].id, created.id);
assert.equal(listed[0].frequency, "MONTHLY");
assert.deepEqual(listed[0].tags.sort(), ["home", "rent"]);
const updated = await updateSchedule({
id: created.id,
groupId,
userId,
entryType: "SPENDING",
amountDollars: 1300,
necessity: "NECESSARY",
purchaseType: "Rent",
notes: "Updated rent",
tags: ["rent"],
startsOn: "2026-03-01",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "AFTER_COUNT",
endCount: 3,
nextRunOn: "2026-04-01",
isActive: true
});
assert.ok(updated);
assert.equal(updated?.amountDollars, 1300);
assert.equal(updated?.endCondition, "AFTER_COUNT");
assert.equal(updated?.endCount, 3);
assert.deepEqual(updated?.tags, ["rent"]);
await deleteSchedule({ id: created.id, groupId, userId });
const afterDelete = await listSchedules(groupId);
assert.equal(afterDelete.length, 0);
} finally {
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});

View File

@ -55,7 +55,9 @@ test("entries CRUD", async t => {
necessity: "NECESSARY",
purchaseType: "Groceries",
notes: "Test",
tags: ["groceries", "weekly"]
tags: ["groceries", "weekly"],
isRecurring: false,
intervalCount: 1
});
const list = await listEntries(groupId);
@ -74,14 +76,19 @@ test("entries CRUD", async t => {
necessity: "BOTH",
purchaseType: "Groceries",
notes: "Updated",
tags: ["groceries"]
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, userId });
await deleteEntry({ id: entry.id, groupId });
const listAfter = await listEntries(groupId);
assert.equal(listAfter.length, 0);
} finally {

View File

@ -32,15 +32,6 @@ export async function cleanupTestData(client: PoolClient, args: CleanupArgs) {
"delete from entry_tags where entry_id in (select id from entries where group_id=$1)",
[groupId]
);
await safeQuery(
"delete from schedule_tags where schedule_id in (select id from schedules where group_id=$1)",
[groupId]
);
await safeQuery(
"delete from entries where source_schedule_id in (select id from schedules where group_id=$1)",
[groupId]
);
await safeQuery("delete from schedules 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]);
@ -73,3 +64,4 @@ export async function cleanupTestDataFromPool(pool: Pool, args: CleanupArgs) {
client.release();
}
}

View File

@ -1,45 +0,0 @@
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 { getUserSettings, setUserSettings } from "../lib/server/user-settings";
import { cleanupTestData } 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("user settings default and update", 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;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`user_settings_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const initial = await getUserSettings(userId);
assert.equal(initial.entryPanelPageSize, 10);
const updated = await setUserSettings({ userId, entryPanelPageSize: 25 });
assert.equal(updated.entryPanelPageSize, 25);
const readBack = await getUserSettings(userId);
assert.equal(readBack.entryPanelPageSize, 25);
} finally {
await cleanupTestData(client, { userIds: [userId] });
client.release();
}
});

View File

@ -2,28 +2,25 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth";
import { loginUser } from "@/lib/server/auth-service";
import { enforceAuthRateLimit } from "@/lib/server/rate-limit";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip } = await getRequestMeta();
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 {
await enforceAuthRateLimit({ route: "login", ip, identifier: email });
if (!email || !password)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_CREDENTIALS", message: "Missing credentials" } }, { status: 400 });
const result = await loginUser({ email, password, remember });
user = result.user;
session = result.session;
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/auth/login", requestId);
const { status, body } = toErrorResponse(e, "POST /api/auth/login");
return NextResponse.json(body, { status });
}
const cookieStore = await cookies();
@ -35,5 +32,5 @@ export async function POST(req: Request) {
path: "/"
});
return NextResponse.json({ requestId, request_id: requestId, user });
return NextResponse.json({ user });
}

View File

@ -2,27 +2,19 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSessionCookieName } from "@/lib/server/auth";
import { logoutUser } from "@/lib/server/auth-service";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function POST() {
const { requestId } = await getRequestMeta();
try {
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: "/"
});
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({ requestId, request_id: requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/auth/logout", requestId);
return NextResponse.json(body, { status });
}
return NextResponse.json({ ok: true });
}

View File

@ -1,17 +1,9 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/server/session";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await getSessionUser();
if (!user)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
return NextResponse.json({ requestId, request_id: requestId, user });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/auth/me", requestId);
return NextResponse.json(body, { status });
}
const user = await getSessionUser();
if (!user)
return NextResponse.json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }, { status: 401 });
return NextResponse.json({ user });
}

View File

@ -2,30 +2,27 @@ 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 { enforceAuthRateLimit } from "@/lib/server/rate-limit";
import { toErrorResponse } from "@/lib/server/errors";
import { getRequestMeta } from "@/lib/server/request";
export async function POST(req: Request) {
const { requestId, ip } = await getRequestMeta();
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 {
await enforceAuthRateLimit({ route: "register", ip, identifier: email });
if (!email || !email.includes("@"))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_EMAIL", message: "Invalid email" } }, { status: 400 });
if (password.length < 8)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "PASSWORD_TOO_SHORT", message: "Password too short" } }, { status: 400 });
const result = await registerUser({ email, password, displayName });
user = result.user;
session = result.session;
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/auth/register", requestId);
const { status, body } = toErrorResponse(e, "POST /api/auth/register");
return NextResponse.json(body, { status });
}
const cookieStore = await cookies();
@ -37,5 +34,5 @@ export async function POST(req: Request) {
path: "/"
});
return NextResponse.json({ requestId, request_id: requestId, user });
return NextResponse.json({ user });
}

View File

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
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();
@ -30,13 +30,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
if (!name)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
const bucket = await updateBucket({
id,
@ -51,9 +51,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (!bucket) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
if (!bucket) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, request_id: requestId, bucket });
return NextResponse.json({ requestId, bucket });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/buckets/[id]", requestId);
return NextResponse.json(body, { status });
@ -67,10 +67,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteBucket({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const buckets = await listBuckets(groupId);
return NextResponse.json({ requestId, request_id: requestId, buckets });
return NextResponse.json({ requestId, buckets });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/buckets", requestId);
return NextResponse.json(body, { status });
@ -38,13 +38,13 @@ export async function POST(req: Request) {
const necessity = String(body?.necessity || "BOTH").toUpperCase();
const windowDays = body?.windowDays != null ? Number(body.windowDays) : 30;
if (!name) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "name is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_BUDGET", message: "Invalid budgetLimitDollars" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_WINDOW_DAYS", message: "Invalid windowDays" } }, { status: 400 });
const bucket = await createBucket({
groupId,
@ -59,10 +59,9 @@ export async function POST(req: Request) {
windowDays
});
return NextResponse.json({ requestId, request_id: requestId, bucket });
return NextResponse.json({ requestId, bucket });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/buckets", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
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);
@ -27,16 +27,30 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
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,
@ -48,12 +62,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
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, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, request_id: requestId, entry });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/entries/[id]", requestId);
return NextResponse.json(body, { status });
@ -67,10 +88,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteEntry({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const entries = await listEntries(groupId);
return NextResponse.json({ requestId, request_id: requestId, entries });
return NextResponse.json({ requestId, entries });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/entries", requestId);
return NextResponse.json(body, { status });
@ -36,16 +36,30 @@ export async function POST(req: Request) {
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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
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,
@ -56,13 +70,19 @@ export async function POST(req: Request) {
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, request_id: requestId, entry });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/entries", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -11,7 +11,7 @@ export async function GET() {
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, request_id: requestId, active });
return NextResponse.json({ requestId, active });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/active", requestId);
return NextResponse.json(body, { status });
@ -24,13 +24,12 @@ export async function POST(req: Request) {
const user = await requireSessionUser();
const body = await req.json().catch(() => null);
const groupId = Number(body?.groupId || 0);
if (!groupId) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_GROUP_ID", message: "groupId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

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

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, deleteGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
@ -10,10 +10,9 @@ export async function POST() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
await deleteGroup({ userId: user.id, groupId, requestId, ip, userAgent });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { deleteInviteLink } from "@/lib/server/group-invites";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0);
if (!linkId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { reviveInviteLink } from "@/lib/server/group-invites";
@ -14,15 +14,14 @@ export async function POST(req: Request) {
const linkId = Number(body?.linkId || 0);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!linkId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
if (!ttlDays)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { revokeInviteLink } from "@/lib/server/group-invites";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const linkId = Number(body?.linkId || 0);
if (!linkId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_LINK_ID", message: "linkId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const links = await listInviteLinks({ userId: user.id, groupId });
return NextResponse.json({ requestId, request_id: requestId, links });
return NextResponse.json({ requestId, links });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/invites", requestId);
return NextResponse.json(body, { status });
@ -30,13 +30,12 @@ export async function POST(req: Request) {
const singleUse = Boolean(body?.singleUse);
const ttlDays = Math.min(7, Math.max(1, Number(body?.ttlDays || 0)));
if (!ttlDays)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_TTL", message: "ttlDays is required" } }, { status: 400 });
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, request_id: requestId, link });
return NextResponse.json({ requestId, link });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/invites", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,25 +1,22 @@
import { NextResponse } from "next/server";
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";
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
export async function POST(req: Request) {
const { requestId, ip } = await getRequestMeta();
const { requestId } = await getRequestMeta();
try {
await enforceIpRateLimit({ scope: "groups:join:ip", ip, limit: 60 });
const user = await requireSessionUser();
const body = await req.json().catch(() => null);
const inviteCode = String(body?.inviteCode || "").trim().toUpperCase();
if (!inviteCode)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_INVITE_CODE", message: "Invite code is required" } }, { status: 400 });
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, request_id: requestId, group });
return NextResponse.json({ requestId, group });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/join", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { approveJoinRequest } from "@/lib/server/group-members";
@ -14,12 +14,11 @@ export async function POST(req: Request) {
const userId = Number(body?.userId || 0);
const joinRequestId = Number(body?.requestId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { demoteAdmin } from "@/lib/server/group-members";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { denyJoinRequest } from "@/lib/server/group-members";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { kickMember } from "@/lib/server/group-members";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { leaveGroup } from "@/lib/server/group-members";
@ -11,10 +11,9 @@ export async function POST() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
await leaveGroup({ userId: user.id, groupId, requestId, ip, userAgent });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { promoteToAdmin } from "@/lib/server/group-members";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -12,10 +12,9 @@ export async function GET() {
const groupId = await requireActiveGroup(user.id);
const members = await listGroupMembers(groupId);
const requests = await listJoinRequests({ userId: user.id, groupId });
return NextResponse.json({ requestId, request_id: requestId, members, requests, currentUserId: user.id });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import { transferOwnership } from "@/lib/server/group-members";
@ -13,12 +13,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const userId = Number(body?.userId || 0);
if (!userId)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_USER_ID", message: "userId is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup, renameGroup } from "@/lib/server/groups";
import { toErrorResponse } from "@/lib/server/errors";
@ -12,12 +12,11 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const name = String(body?.name || "").trim();
if (!name)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_NAME", message: "Name is required" } }, { status: 400 });
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, request_id: requestId, ok: true });
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

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

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
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";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, request_id: requestId, settings });
return NextResponse.json({ requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/groups/settings", requestId);
return NextResponse.json(body, { status });
@ -30,10 +30,9 @@ export async function POST(req: Request) {
: "NOT_ACCEPTING";
await setGroupSettings({ userId: user.id, groupId, allowMemberTagManage, joinPolicy });
const settings = await getGroupSettings(groupId);
return NextResponse.json({ requestId, request_id: requestId, settings });
return NextResponse.json({ requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/groups/settings", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { getRequestMeta } from "@/lib/server/request";
export async function GET() {
const { requestId } = await getRequestMeta();
return NextResponse.json({
requestId,
request_id: requestId,
ok: true,
status: "live"
});
}

View File

@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import getPool from "@/lib/server/db";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const pool = getPool();
await pool.query("select 1");
return NextResponse.json({
requestId,
request_id: requestId,
ok: true,
status: "ready"
});
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/health/ready", requestId);
return NextResponse.json({
...body,
ok: false,
status: "not_ready"
}, { status });
}
}

View File

@ -3,12 +3,10 @@ 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";
import { enforceIpRateLimit } from "@/lib/server/rate-limit";
export async function GET(_: Request, context: { params: Promise<{ token: string }> }) {
const { requestId, ip } = await getRequestMeta();
const { requestId } = await getRequestMeta();
try {
await enforceIpRateLimit({ scope: "invite-links:get:ip", ip, limit: 120 });
const { token } = await context.params;
const normalized = String(token || "").trim();
if (!normalized) apiError("INVITE_NOT_FOUND");
@ -18,9 +16,9 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
if (user) {
const viewerStatus = await getInviteViewerStatus({ userId: user.id, groupId: link.groupId });
if (viewerStatus)
return NextResponse.json({ requestId, request_id: requestId, link: { ...link, viewerStatus } });
return NextResponse.json({ requestId, link: { ...link, viewerStatus } });
}
return NextResponse.json({ requestId, request_id: requestId, link });
return NextResponse.json({ requestId, link });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/invite-links/[token]", requestId);
return NextResponse.json(body, { status });
@ -30,13 +28,12 @@ export async function GET(_: Request, context: { params: Promise<{ token: string
export async function POST(_: Request, context: { params: Promise<{ token: string }> }) {
const { requestId, ip, userAgent } = await getRequestMeta();
try {
await enforceIpRateLimit({ scope: "invite-links:accept:ip", ip, limit: 60 });
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, request_id: requestId, result });
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

@ -17,7 +17,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
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);
@ -33,21 +33,22 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await updateRecurringEntry({
id,
@ -60,17 +61,19 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
purchaseType,
notes: notes || undefined,
tags,
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
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
nextRunAt,
bucketId
});
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
if (!entry) return NextResponse.json({ requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, request_id: requestId, entry });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/recurring-entries/[id]", requestId);
return NextResponse.json(body, { status });
@ -84,10 +87,10 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const groupId = await requireActiveGroup(user.id);
const { id: idParam } = await params;
const id = Number(idParam || 0);
if (!id) return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
if (!id) return NextResponse.json({ requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteRecurringEntry({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -16,7 +16,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const entries = await listRecurringEntries(groupId);
return NextResponse.json({ requestId, request_id: requestId, entries });
return NextResponse.json({ requestId, entries });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/recurring-entries", requestId);
return NextResponse.json(body, { status });
@ -42,21 +42,22 @@ export async function POST(req: Request) {
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, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!occurredAt) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_OCCURRED_AT", message: "occurredAt is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
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, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
return NextResponse.json({ requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await createRecurringEntry({
groupId,
@ -68,17 +69,19 @@ export async function POST(req: Request) {
purchaseType,
notes: notes || undefined,
tags,
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
isRecurring: true,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate
endDate,
nextRunAt,
bucketId
});
return NextResponse.json({ requestId, request_id: requestId, entry });
return NextResponse.json({ requestId, entry });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/recurring-entries", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,96 +0,0 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { deleteSchedule, requireActiveGroup, updateSchedule } from "@/lib/server/schedules";
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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
const body = await req.json().catch(() => null);
const amountDollars = Number(body?.amountDollars || 0);
const startsOn = String(body?.startsOn || "");
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 = String(body?.frequency || "").toUpperCase();
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER";
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunOn = body?.nextRunOn ? String(body.nextRunOn) : startsOn;
const isActive = body?.isActive != null ? Boolean(body.isActive) : true;
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const schedule = await updateSchedule({
id,
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
startsOn,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY",
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE",
endCount,
endDate,
nextRunOn,
isActive
});
if (!schedule) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });
return NextResponse.json({ requestId, request_id: requestId, schedule });
} catch (e) {
const { status, body } = toErrorResponse(e, "PATCH /api/schedules/[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, request_id: requestId, error: { code: "INVALID_ID", message: "Invalid id" } }, { status: 400 });
await deleteSchedule({ id, groupId, userId: user.id });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
} catch (e) {
const { status, body } = toErrorResponse(e, "DELETE /api/schedules/[id]", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,84 +0,0 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { createSchedule, listSchedules, requireActiveGroup } from "@/lib/server/schedules";
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 schedules = await listSchedules(groupId);
return NextResponse.json({ requestId, request_id: requestId, schedules });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/schedules", 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 startsOn = String(body?.startsOn || "");
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 = String(body?.frequency || "").toUpperCase();
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : "NEVER";
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const createEntryNow = Boolean(body?.createEntryNow);
if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
if (!startsOn) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_STARTS_ON", message: "startsOn is required" } }, { status: 400 });
if (!purchaseType) return NextResponse.json({ requestId, request_id: requestId, error: { code: "MISSING_PURCHASE_TYPE", message: "purchaseType is required" } }, { status: 400 });
if (!['NECESSARY', 'BOTH', 'UNNECESSARY'].includes(necessity))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (!['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const schedule = await createSchedule({
groupId,
userId: user.id,
entryType: entryType as "SPENDING" | "INCOME",
amountDollars,
startsOn,
necessity: necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType,
notes: notes || undefined,
tags,
frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY",
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE",
endCount,
endDate,
createEntryNow
});
return NextResponse.json({ requestId, request_id: requestId, schedule });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/schedules", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -12,7 +12,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ name: s
const groupId = await requireActiveGroup(user.id);
const { name } = await params;
await deleteTagForGroup({ userId: user.id, groupId, name: decodeURIComponent(name) });
return NextResponse.json({ requestId, request_id: requestId, ok: true });
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

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/entries";
import { ensureTagsForGroup, listGroupTags } from "@/lib/server/tags";
@ -11,7 +11,7 @@ export async function GET() {
const user = await requireSessionUser();
const groupId = await requireActiveGroup(user.id);
const tags = await listGroupTags(groupId);
return NextResponse.json({ requestId, request_id: requestId, tags });
return NextResponse.json({ requestId, tags });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/tags", requestId);
return NextResponse.json(body, { status });
@ -27,10 +27,9 @@ export async function POST(req: Request) {
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, request_id: requestId, tags: list });
return NextResponse.json({ requestId, tags: list });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/tags", requestId);
return NextResponse.json(body, { status });
}
}

View File

@ -1,33 +0,0 @@
import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session";
import { getUserSettings, setUserSettings } from "@/lib/server/user-settings";
import { getRequestMeta } from "@/lib/server/request";
import { toErrorResponse } from "@/lib/server/errors";
export async function GET() {
const { requestId } = await getRequestMeta();
try {
const user = await requireSessionUser();
const settings = await getUserSettings(user.id);
return NextResponse.json({ requestId, request_id: requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "GET /api/user/settings", 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 entryPanelPageSize = Number(body?.entryPanelPageSize);
if (!Number.isFinite(entryPanelPageSize) || entryPanelPageSize <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_PAGE_SIZE", message: "entryPanelPageSize must be a positive number" } }, { status: 400 });
const settings = await setUserSettings({ userId: user.id, entryPanelPageSize });
return NextResponse.json({ requestId, request_id: requestId, settings });
} catch (e) {
const { status, body } = toErrorResponse(e, "POST /api/user/settings", requestId);
return NextResponse.json(body, { status });
}
}

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

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthContext } from "@/hooks/auth-context";
import useInviteLink from "@/features/groups/hooks/use-invite-link";
import useInviteLink from "@/hooks/use-invite-link";
export default function InvitePage() {
const params = useParams();
@ -154,7 +154,7 @@ export default function InvitePage() {
<div className="card-title">Invite details</div>
</div>
{loading ? (
<div className="text-sm text-muted">Loading invite...</div>
<div className="text-sm text-muted">Loading invite</div>
) : error ? (
<div className="text-sm text-red-400">{error}</div>
) : link ? (
@ -203,7 +203,7 @@ export default function InvitePage() {
<div className="card-title">Join this group</div>
</div>
{checkingSession ? (
<div className="text-sm text-muted">Checking session...</div>
<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>
@ -244,7 +244,7 @@ export default function InvitePage() {
disabled={!link || Boolean(result) || accepting}
onClick={accept}
>
{accepting ? "Joining..." : actionLabel}
{accepting ? "Joining" : actionLabel}
</button>
) : null}
<button
@ -280,4 +280,3 @@ export default function InvitePage() {
</div>
);
}

View File

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

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

@ -2,24 +2,24 @@
import { useEffect, useMemo, useState } from "react";
import { useGroupsContext } from "@/hooks/groups-context";
import useBuckets from "@/features/buckets/hooks/use-buckets";
import useTags from "@/features/tags/hooks/use-tags";
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";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function BucketsPanel() {
const { activeGroupId } = useGroupsContext();
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
const { mutationVersion } = useEntryMutation();
const { notify } = useNotificationsContext();
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: "",
@ -33,6 +33,18 @@ export default function BucketsPanel() {
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;
@ -63,6 +75,7 @@ export default function BucketsPanel() {
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
});
setModalOpen(true);
setMenuOpenId(null);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
@ -84,10 +97,7 @@ export default function BucketsPanel() {
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) {
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
setModalOpen(false);
}
if (ok) setModalOpen(false);
} else {
const ok = await createBucket({
name: form.name.trim(),
@ -98,17 +108,40 @@ export default function BucketsPanel() {
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays
});
if (ok) {
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
setModalOpen(false);
}
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;
return { limit, spent };
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 (
@ -126,7 +159,7 @@ export default function BucketsPanel() {
+
</button>
</div>
<div className="mt-3 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{!activeGroupId ? (
@ -148,14 +181,21 @@ export default function BucketsPanel() {
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}
/>
})
) : (
@ -172,11 +212,6 @@ export default function BucketsPanel() {
onSubmit={handleSubmit}
onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
canDelete={Boolean(editId)}
onDelete={() => {
if (!editId) return;
setConfirmDeleteId(editId);
}}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmDeleteId)}
@ -186,21 +221,10 @@ export default function BucketsPanel() {
onClose={() => setConfirmDeleteId(null)}
onConfirm={async () => {
if (!confirmDeleteId) return;
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
const ok = await deleteBucket(confirmDeleteId);
if (ok) {
notify({
title: "Bucket deleted",
message: deletedBucket?.name || "Bucket removed",
tone: "danger"
});
setConfirmDeleteId(null);
setModalOpen(false);
resetForm();
}
if (ok) setConfirmDeleteId(null);
}}
/>
</>
);
}

View File

@ -1,92 +0,0 @@
"use client";
import { useEffect, useMemo } from "react";
type ConfirmRetypeModalProps = {
isOpen: boolean;
title: string;
description?: string;
expectedText: string;
value: string;
onChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
};
function normalizeRetype(text: string) {
return text.trim().toUpperCase();
}
export default function ConfirmRetypeModal({
isOpen,
title,
description,
expectedText,
value,
onChange,
onClose,
onConfirm,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
placeholder
}: ConfirmRetypeModalProps) {
const expectedNormalized = useMemo(() => normalizeRetype(expectedText), [expectedText]);
const canConfirm = normalizeRetype(value) === expectedNormalized;
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented && canConfirm) {
onConfirm();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [canConfirm, isOpen, onClose, onConfirm]);
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()}
role="dialog"
tabIndex={-1}
>
<div className="text-lg font-semibold text-red-200">{title}</div>
{description ? <p className="mt-2 text-sm text-muted">{description}</p> : null}
<input
className={`mt-4 w-full input-base px-3 py-2 text-sm ${canConfirm ? "" : "border-red-400/70"}`}
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder || expectedText}
/>
<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={onClose}
>
{cancelLabel}
</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 disabled:opacity-40"
disabled={!canConfirm}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -20,60 +20,23 @@ export default function ConfirmSlideModal({
onConfirm
}: ConfirmSlideModalProps) {
const trackRef = useRef<HTMLDivElement | null>(null);
const endFlashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reachedEndRef = useRef(false);
const [dragX, setDragX] = useState(0);
const [dragging, setDragging] = useState(false);
const [isAtEnd, setIsAtEnd] = useState(false);
const [endFlash, setEndFlash] = useState(false);
const handleSize = 40;
function getDragPositionFromClientX(clientX: number) {
const track = trackRef.current;
if (!track) return 0;
const rect = track.getBoundingClientRect();
return Math.min(Math.max(0, clientX - rect.left - handleSize / 2), rect.width - handleSize);
}
function isEndPosition(position: number) {
const track = trackRef.current;
if (!track) return false;
const maxDrag = track.clientWidth - handleSize;
const endTolerancePx = 1;
return position >= maxDrag - endTolerancePx;
}
function triggerEndFeedback() {
setEndFlash(true);
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140);
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(16);
}
}
const handleSize = 44;
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.preventDefault();
setDragging(true);
reachedEndRef.current = false;
setIsAtEnd(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function handlePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
if (!dragging) return;
const next = getDragPositionFromClientX(event.clientX);
const nextAtEnd = isEndPosition(next);
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);
setIsAtEnd(prev => (prev === nextAtEnd ? prev : nextAtEnd));
if (nextAtEnd && !reachedEndRef.current) {
reachedEndRef.current = true;
triggerEndFeedback();
}
if (!nextAtEnd) reachedEndRef.current = false;
}
function handlePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
@ -82,35 +45,14 @@ export default function ConfirmSlideModal({
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
const track = trackRef.current;
if (!track) return;
const releaseX = getDragPositionFromClientX(event.clientX);
const releaseAtEnd = isEndPosition(releaseX);
setIsAtEnd(prev => (prev ? false : prev));
if (releaseAtEnd && !reachedEndRef.current) {
triggerEndFeedback();
}
if (releaseAtEnd) {
const threshold = (track.clientWidth - handleSize) * 0.8;
if (dragX >= threshold) {
setDragX(0);
onConfirm();
} else {
setDragX(0);
}
}
function handlePointerCancel(event: React.PointerEvent<HTMLButtonElement>) {
if (!dragging) return;
setDragging(false);
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
setIsAtEnd(prev => (prev ? false : prev));
setDragX(0);
}
useEffect(() => () => {
if (endFlashTimeoutRef.current) clearTimeout(endFlashTimeoutRef.current);
}, []);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
@ -131,26 +73,20 @@ export default function ConfirmSlideModal({
<div className="text-xs text-soft">Slide to confirm</div>
<div
ref={trackRef}
className={`relative mx-auto mt-2 h-10 w-4/5 overflow-hidden rounded-full border touch-none select-none transition-colors ${isAtEnd || endFlash ? "border-accent bg-accent-soft" : "border-accent-weak bg-surface"}`}
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 rounded-full bg-accent-soft"
className="absolute inset-y-0 left-0 bg-accent-soft rounded-full"
style={{ width: dragX + handleSize }}
/>
<div
className={`pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-[color:var(--color-accent)] transition-all ${isAtEnd || endFlash ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
aria-hidden="true"
>
click
</div>
<button
type="button"
className={`absolute left-0 top-0 h-10 w-10 rounded-full border bg-panel text-lg font-semibold leading-none text-[color:var(--color-text)] touch-none select-none will-change-transform transition-[border-color,box-shadow] duration-100 ${isAtEnd || endFlash ? "border-accent-strong shadow-[0_0_0_2px_var(--color-accent-focus)]" : "border-accent"}`}
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={handlePointerCancel}
onPointerCancel={handlePointerUp}
aria-label="Slide to confirm"
>

View File

@ -1,8 +1,8 @@
"use client";
import { useGroupsContext } from "@/hooks/groups-context";
import EntriesPanel from "@/features/entries/components/entries-panel";
import BucketsPanel from "@/features/buckets/components/buckets-panel";
import EntriesPanel from "@/components/entries-panel";
import BucketsPanel from "@/components/buckets-panel";
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";

View File

@ -1,51 +0,0 @@
"use client";
type DatePickerProps = {
value: string;
onChange: (value: string) => void;
required?: boolean;
name?: string;
className?: string;
showWeekButtons?: boolean;
centerInput?: boolean;
};
export default function DatePicker({
value,
onChange,
required = false,
name,
className = "",
showWeekButtons = true,
centerInput = false
}: DatePickerProps) {
function shiftDate(days: number) {
const base = value ? new Date(value) : new Date();
if (Number.isNaN(base.getTime())) return;
base.setDate(base.getDate() + days);
onChange(base.toISOString().slice(0, 10));
}
const invalid = required && !value;
return (
<div className={`inline-flex w-full items-center overflow-hidden rounded-full border ${invalid ? "border-red-400/70" : "border-accent-weak"} bg-panel ${className}`}>
{showWeekButtons ? (
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
) : null}
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}></button>
<input
name={name}
type="date"
className={`no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none ${centerInput ? "text-center" : ""}`}
value={value}
onChange={e => onChange(e.target.value)}
required={required}
/>
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(1)}></button>
{showWeekButtons ? (
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(7)}>»</button>
) : null}
</div>
);
}

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

@ -3,8 +3,6 @@
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/components/date-picker";
export type EntryDetailsForm = {
amountDollars: string;
@ -13,6 +11,12 @@ export type EntryDetailsForm = {
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 = {
@ -77,6 +81,7 @@ export default function EntryDetailsModal({
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);
@ -112,7 +117,10 @@ export default function EntryDetailsModal({
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="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()}
@ -121,14 +129,26 @@ export default function EntryDetailsModal({
onTouchEnd={handleTouchEnd}
>
<div className="grid grid-cols-3 items-center gap-3">
<button type="button" onClick={onPrev} className="flex w-20 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 ? "o" : "<"}</span>
<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-20 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">
<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 ? "o" : ">"}</span>
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span>
</button>
</div>
<form
@ -144,16 +164,31 @@ export default function EntryDetailsModal({
className="mt-3 grid gap-3 md:grid-cols-2"
>
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending" },
{ value: "INCOME", label: "Income" }
]}
/>
<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 ($)
@ -169,27 +204,32 @@ export default function EntryDetailsModal({
/>
</label>
<div className="text-sm text-muted">
<DatePicker
<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={occurredAt => onChange({ occurredAt })}
onChange={e => onChange({ occurredAt: e.target.value })}
required
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
/>
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
<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"
@ -206,6 +246,80 @@ export default function EntryDetailsModal({
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
@ -218,7 +332,7 @@ export default function EntryDetailsModal({
/>
</label>
<div className="md:col-span-2 flex items-center justify-between">
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRevert}
@ -226,16 +340,41 @@ export default function EntryDetailsModal({
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"
>
R
<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={!isDirty}>
<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}>
<button
type="button"
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
onClick={onRequestDelete}
>
Delete
</button>
<div className="flex-1 w-full" />
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" aria-label="Close">
<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>

View File

@ -2,18 +2,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useTags from "@/features/tags/hooks/use-tags";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useGroupMembers from "@/features/groups/hooks/use-group-members";
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
import useTags from "@/hooks/use-tags";
import useGroupSettings from "@/hooks/use-group-settings";
import useGroupMembers from "@/hooks/use-group-members";
import useGroupInvites from "@/hooks/use-group-invites";
import useGroupAudit from "@/hooks/use-group-audit";
import { useGroupsContext } from "@/hooks/groups-context";
import TagInput from "@/components/tag-input";
import { useNotificationsContext } from "@/hooks/notifications-context";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
import { groupsDelete, groupsRename } from "@/lib/client/groups";
import ToggleButtonGroup from "@/components/toggle-button-group";
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
const router = useRouter();
@ -269,24 +267,6 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
return { userId: top, count: topCount, name, searchValue };
})();
const mostActiveCount = mostActiveUser?.count ?? 0;
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
async function handleDeleteGroup() {
const result = await groupsDelete();
@ -308,9 +288,30 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
);
}
const renameDirty = renameValue.trim() !== group.name;
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
const hasMoreTags = tags.length > 5;
const tagsScrollable = showAllTags && tags.length > 15;
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen && !confirmDeleteGroupOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (confirmDeleteGroupOpen) setConfirmDeleteGroupOpen(false);
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
// if (tagModalOpen && pendingTags.length && canManageTags) handleSaveTags();
if (confirmDeleteGroupOpen && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, confirmDeleteGroupOpen, renameDirty, isAdmin, renameValue, pendingTags.length, canManageTags, deleteConfirmText, handleDeleteGroup, handleSaveTags, handleCloseRenameModal]);
return (
<>
@ -424,21 +425,23 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Join policy</div>
<ToggleButtonGroup
value={localJoinPolicy}
onChange={policy => handleJoinPolicyChange(policy)}
ariaLabel="Join policy"
className="flex flex-wrap gap-2"
buttonBaseClassName="rounded-lg border"
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
activeClassName="border-accent bg-accent-soft"
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
options={[
<div className="flex flex-wrap gap-2">
{[
{ value: "NOT_ACCEPTING", label: "Disabled" },
{ value: "AUTO_ACCEPT", label: "Auto" },
{ value: "APPROVAL_REQUIRED", label: "Manual" }
]}
/>
].map(option => (
<button
key={option.value}
type="button"
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition ${localJoinPolicy === option.value ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
onClick={() => handleJoinPolicyChange(option.value as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED")}
aria-pressed={localJoinPolicy === option.value}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="divider" />
@ -515,55 +518,42 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
{(() => {
const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
const options = [
{
value: "COPY",
label: "Copy link",
className: "btn-outline-accent",
disabled: localJoinPolicy === "NOT_ACCEPTING",
onClick: () => handleCopyInvite(link.token)
},
...(showRevive
? [{
value: "REVIVE",
label: "Revive",
className: "btn-outline-accent",
disabled: localJoinPolicy === "NOT_ACCEPTING",
onClick: () => reviveInvite(link.id, inviteTtlDays)
}]
: showRevoke
? [{
value: "REVOKE",
label: "Revoke",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => revokeInvite(link.id)
}]
: []),
{
value: "DELETE",
label: "Delete",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
}
];
return (
<ToggleButtonGroup
value={null}
ariaLabel="Invite actions"
className="flex items-center gap-2"
buttonBaseClassName="rounded-lg"
sizeClassName="px-2 py-1 text-xs"
activeClassName=""
inactiveClassName=""
options={options}
/>
);
})()}
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}{link.token.slice(-4)}</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => handleCopyInvite(link.token)}
disabled={localJoinPolicy === "NOT_ACCEPTING"}
>
Copy link
</button>
{(link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt)) ? (
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => reviveInvite(link.id, inviteTtlDays)}
disabled={localJoinPolicy === "NOT_ACCEPTING"}
>
Revive
</button>
) : (!link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt)) ? (
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
onClick={() => revokeInvite(link.id)}
>
Revoke
</button>
) : null}
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
onClick={() => setConfirmDeleteInvite({ id: link.id, token: link.token })}
>
Delete
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
@ -587,10 +577,10 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
{members.map(member => {
const isSelf = member.userId === currentUserId;
const privilegeLabel = member.role === "GROUP_OWNER"
? "Owner - Full control"
? "👑 Owner · Full control"
: member.role === "GROUP_ADMIN"
? "Admin - Manage members"
: "Member - Entries only";
? "🛡️ Admin · Manage members"
: "👤 Member · Entries only";
return (
<div
key={member.userId}
@ -816,7 +806,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
onClick={handleCloseRenameModal}
aria-label="Close"
>
x
</button>
<div className="text-lg font-semibold">Change group name</div>
<input
@ -877,7 +867,9 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Edit tags</div>
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">x</button>
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">
</button>
</div>
<div className="mt-4 space-y-3">
<TagInput
@ -946,7 +938,7 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
<ConfirmSlideModal
isOpen={Boolean(confirmDeleteInvite)}
title="Delete invite link"
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}...${confirmDeleteInvite.token.slice(-4)}?` : ""}
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}${confirmDeleteInvite.token.slice(-4)}?` : ""}
confirmLabel="Delete link"
onClose={() => setConfirmDeleteInvite(null)}
onConfirm={async () => {
@ -1008,17 +1000,46 @@ export default function GroupSettingsContent({ groupId }: { groupId: number }) {
if (ok) notify({ title: "Ownership transferred", message: target.name });
}}
/>
<ConfirmRetypeModal
isOpen={confirmDeleteGroupOpen}
title="Delete group"
description="Type DELETE to confirm. This cannot be undone."
expectedText="DELETE"
value={deleteConfirmText}
onChange={setDeleteConfirmText}
confirmLabel="Delete"
onClose={() => setConfirmDeleteGroupOpen(false)}
onConfirm={handleDeleteGroup}
/>
{confirmDeleteGroupOpen ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setConfirmDeleteGroupOpen(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") setConfirmDeleteGroupOpen(false);
if (event.key === "Enter" && deleteConfirmText.trim().toUpperCase() === "DELETE") handleDeleteGroup();
}}
role="dialog"
tabIndex={-1}
>
<div className="text-lg font-semibold text-red-200">Delete group</div>
<p className="mt-2 text-sm text-muted">Type DELETE to confirm. This cannot be undone.</p>
<input
className={`mt-4 w-full input-base px-3 py-2 text-sm ${deleteConfirmText.trim().toUpperCase() === "DELETE" ? "" : "border-red-400/70"}`}
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder="DELETE"
/>
<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={() => setConfirmDeleteGroupOpen(false)}
>
Cancel
</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 disabled:opacity-40"
disabled={deleteConfirmText.trim().toUpperCase() !== "DELETE"}
onClick={handleDeleteGroup}
>
Delete
</button>
</div>
</div>
</div>
) : null}
</div>
</>
);

View File

@ -127,7 +127,8 @@ export default function Navbar() {
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-xs font-semibold"
onClick={() => {
setUserMenuOpen(false);
router.push("/settings");
if (activeGroupId) router.push("/groups/settings");
else router.push("/");
}}
>
Settings

View File

@ -4,7 +4,6 @@ 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";
import ToggleButtonGroup from "@/components/toggle-button-group";
type BucketForm = {
name: string;
@ -25,11 +24,9 @@ type NewBucketModalProps = {
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<BucketForm>) => void;
tagSuggestions: string[];
canDelete?: boolean;
onDelete?: () => void;
};
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
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);
@ -128,18 +125,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
/>
</label>
<div className="text-sm text-muted md:col-span-2">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
<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
@ -159,22 +160,11 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
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 gap-3">
<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 className="ml-auto flex items-center gap-2">
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
Save bucket
</button>
{canDelete ? (
<button
type="button"
className="rounded-lg border border-red-400/70 bg-red-500/10 px-4 py-2 text-sm font-semibold text-red-200 hover:bg-red-500/15"
onClick={onDelete}
>
Delete bucket
</button>
) : null}
</div>
</div>
</form>
</div>

View File

@ -3,8 +3,6 @@
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/components/date-picker";
type NewEntryForm = {
amountDollars: string;
@ -13,6 +11,12 @@ type NewEntryForm = {
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 = {
@ -31,6 +35,7 @@ type NewEntryModalProps = {
};
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);
@ -42,12 +47,23 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
if (!form.occurredAt) {
const today = new Date().toISOString().slice(0, 10);
onChange({ occurredAt: today });
onChange({ occurredAt: today, endDate: form.endDate || today });
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [form.occurredAt, isOpen, onChange, onClose]);
}, [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;
@ -61,28 +77,44 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
<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"
>
x
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
{ value: "INCOME", label: "Income" }
]}
/>
<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}
@ -97,9 +129,10 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
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" ? "$" : "$"}
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"}
</span>
<input
ref={amountInputRef}
@ -115,28 +148,41 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
</div>
</label>
<div className="text-sm text-muted">
<DatePicker
name="occurredAt"
value={form.occurredAt}
onChange={occurredAt => onChange({ occurredAt })}
required
className="mt-1"
/>
<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">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
<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}
@ -150,6 +196,80 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
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

View File

@ -1,226 +0,0 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import DatePicker from "@/components/date-picker";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export type NewScheduleForm = {
amountDollars: string;
startsOn: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
frequency: ScheduleFrequency;
intervalCount: number;
endCondition: ScheduleEndCondition;
endCount: string;
endDate: string;
createEntryNow: boolean;
};
type NewScheduleModalProps = {
isOpen: boolean;
form: NewScheduleForm;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<NewScheduleForm>) => void;
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
};
export default function NewScheduleModal({
isOpen,
form,
error,
onClose,
onSubmit,
onChange,
tagSuggestions,
emptyTagActionLabel,
emptyTagActionDisabled = false,
onEmptyTagAction
}: NewScheduleModalProps) {
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
if (!form.startsOn) {
const today = new Date().toISOString().slice(0, 10);
onChange({ startsOn: today });
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [form.startsOn, isOpen, onChange, 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="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 Schedule</h2>
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
x
</button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
{ value: "INCOME", label: "Income" }
]}
/>
<ToggleButtonGroup
value={form.createEntryNow ? "NOW" : "NEXT"}
onChange={value => onChange({ createEntryNow: value === "NOW" })}
ariaLabel="Create behavior"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "NOW", label: "Create Entry Now" },
{ value: "NEXT", label: "Start Next Schedule" }
]}
/>
</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 ($)
<input
name="amountDollars"
type="number"
min={0}
step="0.01"
className={`mt-1 w-full input-base px-3 py-2 text-sm ${form.amountDollars ? "" : "border-red-400/70"}`}
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</label>
<div className="text-sm text-muted">
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
</div>
<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}
/>
<div className="md:col-span-2">
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
<div className="text-sm text-muted mr-2">Every</div>
<input
type="number"
min={1}
className="mr-1 w-12 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-[100px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
>
<option value="DAILY">daily</option>
<option value="WEEKLY">weakly</option>
<option value="MONTHLY">monthly</option>
<option value="YEARLY">yearly</option>
</select>
<ToggleButtonGroup
value={form.endCondition}
onChange={endCondition => onChange({ endCondition })}
ariaLabel="End condition"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-3 text-xs font-semibold"
options={[
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
]}
/>
{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" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : null}
</div>
</div>
<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">
Save schedule
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</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

@ -1,261 +0,0 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import DatePicker from "@/components/date-picker";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export type ScheduleDetailsForm = {
amountDollars: string;
startsOn: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
frequency: ScheduleFrequency;
intervalCount: number;
endCondition: ScheduleEndCondition;
endCount: string;
endDate: string;
nextRunOn: string;
isActive: boolean;
};
type ScheduleDetailsModalProps = {
isOpen: boolean;
form: ScheduleDetailsForm;
originalForm: ScheduleDetailsForm | null;
isDirty: boolean;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onRequestDelete: () => void;
onRevert: () => void;
onChange: (next: Partial<ScheduleDetailsForm>) => void;
onAddTag: (tag: string) => void;
onToggleTag: (tag: string) => void;
removedTags: string[];
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
};
export default function ScheduleDetailsModal({
isOpen,
form,
originalForm,
isDirty,
error,
onClose,
onSubmit,
onRequestDelete,
onRevert,
onChange,
onAddTag,
onToggleTag,
removedTags,
tagSuggestions,
emptyTagActionLabel,
emptyTagActionDisabled = false,
onEmptyTagAction
}: ScheduleDetailsModalProps) {
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 addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
const formRef = useRef<HTMLFormElement | null>(null);
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="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">Schedule Details</h2>
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
x
</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 items-center gap-2">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending" },
{ value: "INCOME", label: "Income" }
]}
/>
<ToggleButtonGroup
value={form.isActive ? "ACTIVE" : "PAUSED"}
onChange={value => onChange({ isActive: value === "ACTIVE" })}
ariaLabel="Schedule active status"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ACTIVE", label: "Active" },
{ value: "PAUSED", label: "Paused" }
]}
/>
</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"
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</label>
<div className="text-sm text-muted">
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
</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}
/>
<div className="md:col-span-2">
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
<div className="text-sm text-muted mr-2">Every</div>
<input
type="number"
min={1}
className="mr-1 w-12 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-[100px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
>
<option value="DAILY">daily</option>
<option value="WEEKLY">weakly</option>
<option value="MONTHLY">monthly</option>
<option value="YEARLY">yearly</option>
</select>
<ToggleButtonGroup
value={form.endCondition}
onChange={endCondition => onChange({ endCondition })}
ariaLabel="End condition"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-3 text-xs font-semibold"
options={[
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
]}
/>
{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" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : null}
</div>
</div>
<div className="text-sm text-muted">
Next run
<DatePicker name="nextRunOn" value={form.nextRunOn} onChange={nextRunOn => onChange({ nextRunOn })} required className="mt-1" />
</div>
<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">
<div className="flex items-center gap-2 w-full">
<button type="button" onClick={onRevert} disabled={!isDirty} className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold disabled:opacity-40">
Revert
</button>
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35" 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>
<div className="flex-1 w-full" />
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold">
Close
</button>
</div>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div>
);
}

View File

@ -1,58 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import useUserSettings from "@/hooks/use-user-settings";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function SettingsContent() {
const { settings, loading, error, updateSettings } = useUserSettings();
const { notify } = useNotificationsContext();
const [entryPanelPageSize, setEntryPanelPageSize] = useState("10");
useEffect(() => {
setEntryPanelPageSize(String(settings.entryPanelPageSize || 10));
}, [settings.entryPanelPageSize]);
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const nextSize = Number(entryPanelPageSize);
if (!Number.isFinite(nextSize) || nextSize <= 0) return;
const ok = await updateSettings({ entryPanelPageSize: nextSize });
if (!ok) return;
notify({ title: "Settings saved", message: `Entry panel page size: ${nextSize}`, tone: "success" });
}
if (loading) {
return (
<div className="panel panel-accent p-4">
<div className="text-sm text-muted">Loading settings...</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="panel panel-accent p-4">
<h1 className="text-xl font-semibold">User settings</h1>
<p className="mt-1 text-sm text-muted">These settings apply to your account across groups.</p>
<form className="mt-4 space-y-3" onSubmit={handleSave}>
<label className="block text-sm text-muted">
Entry/Schedule cards per page
<input
type="number"
min={1}
max={100}
value={entryPanelPageSize}
onChange={event => setEntryPanelPageSize(event.target.value)}
className="mt-1 w-40 input-base px-3 py-2 text-sm"
/>
</label>
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
Save
</button>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</form>
</div>
</div>
);
}

View File

@ -106,6 +106,7 @@ export default function TagInput({ label, labelAction, tags, suggestions, remove
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;

View File

@ -1,77 +0,0 @@
"use client";
type ToggleButtonOption<T extends string> = {
value: T;
label: string;
className?: string;
activeClassName?: string;
inactiveClassName?: string;
disabled?: boolean;
ariaLabel?: string;
onClick?: () => void;
};
type ToggleButtonGroupProps<T extends string> = {
value?: T | null;
options: ToggleButtonOption<T>[];
onChange?: (value: T) => void;
ariaLabel?: string;
role?: "group" | "radiogroup";
className?: string;
buttonBaseClassName?: string;
buttonClassName?: string;
activeClassName?: string;
inactiveClassName?: string;
sizeClassName?: string;
};
function joinClasses(parts: Array<string | undefined | null | false>) {
return parts.filter(Boolean).join(" ");
}
export default function ToggleButtonGroup<T extends string>({
value,
options,
onChange,
ariaLabel,
role = "group",
className = "flex items-center gap-0 rounded-full border border-accent-weak bg-panel",
buttonBaseClassName = "rounded-full",
buttonClassName,
activeClassName = "btn-accent",
inactiveClassName = "text-muted",
sizeClassName = "px-3 py-2 text-xs font-semibold"
}: ToggleButtonGroupProps<T>) {
return (
<div className={className} role={role} aria-label={ariaLabel}>
{options.map(option => {
const isActive = value != null && option.value === value;
const onClick = option.onClick
? option.onClick
: onChange
? () => onChange(option.value)
: undefined;
return (
<button
key={option.value}
type="button"
className={joinClasses([
buttonBaseClassName,
sizeClassName,
buttonClassName,
isActive ? option.activeClassName ?? activeClassName : option.inactiveClassName ?? inactiveClassName,
option.className
])}
onClick={onClick}
disabled={option.disabled}
aria-pressed={value != null ? isActive : undefined}
aria-label={option.ariaLabel}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@ -11,7 +11,7 @@ test("login and register hide navbar", async ({ page }) => {
test("login shows entries for seeded owner", async ({ page }) => {
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
});
test("no-group user sees empty state", async ({ page }) => {

View File

@ -5,7 +5,7 @@ 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: /▼$/ });
const dropdown = page.getByRole("button", { name: /Group:/ });
await dropdown.click();
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
@ -18,7 +18,7 @@ test("group settings show join requests and policy", async ({ page }) => {
await expect(page).toHaveURL("/");
await page.getByRole("button", { name: "Group settings" }).click();
await expect(page).toHaveURL(/\/groups\/settings/);
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
await expect(page.getByText("Join requests")).toBeVisible();
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();

View File

@ -5,18 +5,18 @@ 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("button", { name: "Entries" })).toBeVisible();
await expect(page.getByText("$12.50").first()).toBeVisible();
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
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").first().click();
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();
@ -26,14 +26,13 @@ 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: /▼$/ });
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.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
await expect(callout).toBeVisible();
await expect(callout).toBeDisabled();
@ -43,14 +42,13 @@ 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: /▼$/ });
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.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
await expect(page).toHaveURL(/\/groups\/settings/);
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
});

View File

@ -1,15 +0,0 @@
import nextVitals from "eslint-config-next/core-web-vitals";
const config = [
...nextVitals,
{
rules: {
"react-hooks/error-boundaries": "off",
"react-hooks/immutability": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off"
}
}
];
export default config;

View File

@ -1,9 +0,0 @@
# Features
Domain-first frontend modules live here.
Current migrated domains:
- entries (components)
- buckets (components)
Future migrations should move domain-specific components/hooks/lib into these folders incrementally.

View File

@ -1,3 +0,0 @@
# Auth Feature
Reserved for auth domain modules (components/hooks/lib) during incremental migration.

View File

@ -1,181 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import type { Bucket } from "@/lib/client/buckets";
type BucketCardProps = {
bucket: Bucket;
icon?: string | null;
openEdit: (bucketId: number) => void;
limit: number;
usageLabel: string;
};
const TAG_GAP_PX = 8;
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]";
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-[11px] text-soft";
function BucketTagsRow({ tags }: { tags: string[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLSpanElement | null>(null);
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [visibleCount, setVisibleCount] = useState(tags.length);
useEffect(() => {
tagRefs.current = tagRefs.current.slice(0, tags.length);
setVisibleCount(tags.length);
}, [tags]);
useEffect(() => {
if (!tags.length) return;
function recomputeVisibleCount() {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
const moreProbe = moreRef.current;
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
setVisibleCount(tags.length);
return;
}
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
if (totalTagsWidth <= containerWidth) {
setVisibleCount(tags.length);
return;
}
let nextVisibleCount = 0;
let usedWidth = 0;
for (let index = 0; index < widths.length; index += 1) {
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
const remaining = widths.length - (index + 1);
if (remaining <= 0) {
nextVisibleCount = widths.length;
break;
}
moreProbe.textContent = `${remaining} more...`;
const moreWidth = moreProbe.offsetWidth;
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
else break;
}
setVisibleCount(nextVisibleCount);
}
recomputeVisibleCount();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(recomputeVisibleCount);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}
window.addEventListener("resize", recomputeVisibleCount);
return () => window.removeEventListener("resize", recomputeVisibleCount);
}, [tags]);
if (!tags.length) {
return <span className="text-[11px] text-soft">No tags</span>;
}
const visibleTags = tags.slice(0, visibleCount);
const hasOverflow = visibleCount < tags.length;
const remainingCount = tags.length - visibleCount;
return (
<div className="relative w-full">
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleTags.map((tag, index) => (
<span key={`${tag}-${index}`} className={TAG_CLASS}>
#{tag}
</span>
))}
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
</div>
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
{tags.map((tag, index) => (
<span
key={`${tag}-${index}`}
ref={element => {
tagRefs.current[index] = element;
}}
className={`${TAG_CLASS} inline-block`}
>
#{tag}
</span>
))}
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
{tags.length} more...
</span>
</div>
</div>
);
}
export function BucketCard({
bucket,
icon,
openEdit,
limit,
usageLabel,
}: BucketCardProps) {
const spent = bucket.totalUsage || 0;
const rawPercent = limit > 0 ? (spent / limit) * 100 : 0;
const progressPercent = Math.max(0, Math.min(100, rawPercent));
const progressColor = rawPercent > 100 ? "#ef4444" : rawPercent >= 80 ? "#facc15" : "#4ade80";
const ringTrackColor = "rgba(148, 163, 184, 0.25)";
return (
<div
className="w-full cursor-pointer rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
onClick={() => openEdit(bucket.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
{limit > 0 ? (
<div className="relative h-11 w-11 shrink-0">
<div
className="absolute inset-0 rounded-full"
style={{
background: `conic-gradient(${progressColor} 0% ${progressPercent}%, ${ringTrackColor} ${progressPercent}% 100%)`,
}}
/>
<div className="absolute inset-[5px] flex items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
{icon || "?"}
</div>
</div>
) : (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
{icon || "?"}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{bucket.name}</div>
{bucket.description ? (
<div className="text-xs text-soft">
{bucket.description}
</div>
) : null}
</div>
</div>
</div>
<div className="mt-2 space-y-2 text-xs text-soft">
{limit > 0 ? <div>{usageLabel}</div> : null}
<div className="flex min-w-0 items-center">
<BucketTagsRow tags={bucket.tags || []} />
</div>
</div>
</div>
);
}
export default React.memo(BucketCard, (prev, next) => (
prev.bucket === next.bucket
&& prev.icon === next.icon
&& prev.limit === next.limit
&& prev.usageLabel === next.usageLabel
));

View File

@ -1,43 +0,0 @@
"use client";
type EntriesDiscardModalProps = {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export default function EntriesDiscardModal({ isOpen, onConfirm, onCancel }: EntriesDiscardModalProps) {
if (!isOpen) return null;
return (
<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") onCancel();
}}
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={onCancel}
>
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={onConfirm}
>
Discard
</button>
</div>
</div>
</div>
);
}

View File

@ -1,185 +0,0 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
export type EntriesFilters = {
amountMin: string;
amountMax: string;
dateFrom: string;
dateTo: string;
necessity: "ANY" | "NECESSARY" | "BOTH" | "UNNECESSARY";
notesQuery: string;
tags: string[];
tagsMode: "ANY" | "ALL";
};
type EntriesFilterModalProps = {
isOpen: boolean;
filters: EntriesFilters;
setFilters: Dispatch<SetStateAction<EntriesFilters>>;
activeFilterCount: number;
tagSuggestions: string[];
canManageTags: boolean;
emptyTagActionLabel: string;
onEmptyTagAction: () => void;
onClearFilters: () => void;
onFilterAddTag: (tag: string) => void;
onFilterToggleTag: (tag: string) => void;
onClose: () => void;
};
export default function EntriesFilterModal({
isOpen,
filters,
setFilters,
activeFilterCount,
tagSuggestions,
canManageTags,
emptyTagActionLabel,
onEmptyTagAction,
onClearFilters,
onFilterAddTag,
onFilterToggleTag,
onClose
}: EntriesFilterModalProps) {
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()}
onKeyDown={event => {
if (event.key === "Escape") onClose();
}}
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={onClose}
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
x
</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">
<ToggleButtonGroup
value={filters.necessity}
onChange={necessity => setFilters(prev => ({ ...prev, necessity: prev.necessity === necessity ? "ANY" : necessity }))}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ANY", label: "Any" },
{ value: "NECESSARY", label: "Necessary" },
{ value: "BOTH", label: "Both" },
{ value: "UNNECESSARY", label: "Unnecessary" }
]}
/>
</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={
<ToggleButtonGroup
value={filters.tagsMode}
onChange={tagsMode => setFilters(prev => ({ ...prev, tagsMode }))}
ariaLabel="Tags mode"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ANY", label: "Any" },
{ value: "ALL", label: "All" }
]}
/>
}
tags={filters.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={onFilterToggleTag}
onAddTag={onFilterAddTag}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={!canManageTags}
onEmptySuggestionClick={onEmptyTagAction}
/>
</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={onClearFilters}>
Clear Filters
</button>
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={onClose}>
Apply
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,232 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Entry } from "@/lib/shared/types";
type EntriesListProps = {
activeGroupId: number | null;
loading: boolean;
entries: Entry[];
visibleEntries: Entry[];
activeFilterCount: number;
onOpenDetails: (entry: Entry, index: number) => void;
onClearFilters: () => void;
};
const TAG_GAP_PX = 8;
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs";
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft";
function NecessityIcon({ necessity }: { necessity: Entry["necessity"] }) {
if (necessity === "NECESSARY") {
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-accent-weak bg-accent-soft text-[color:var(--color-accent)]"
title="Necessary"
aria-label="Necessary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
);
}
if (necessity === "UNNECESSARY") {
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-red-400/60 bg-red-500/10 text-red-200"
title="Unnecessary"
aria-label="Unnecessary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m18 6-12 12" />
<path d="m6 6 12 12" />
</svg>
</span>
);
}
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-amber-200"
title="Both"
aria-label="Both"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 9h6" />
<path d="M9 15h6" />
<path d="M12 6v6" />
</svg>
</span>
);
}
function EntryTagsRow({ tags }: { tags: string[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLSpanElement | null>(null);
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [visibleCount, setVisibleCount] = useState(tags.length);
useEffect(() => {
tagRefs.current = tagRefs.current.slice(0, tags.length);
setVisibleCount(tags.length);
}, [tags]);
useEffect(() => {
if (!tags.length) return;
function recomputeVisibleCount() {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
const moreProbe = moreRef.current;
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
setVisibleCount(tags.length);
return;
}
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
if (totalTagsWidth <= containerWidth) {
setVisibleCount(tags.length);
return;
}
let nextVisibleCount = 0;
let usedWidth = 0;
for (let index = 0; index < widths.length; index += 1) {
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
const remaining = widths.length - (index + 1);
if (remaining <= 0) {
nextVisibleCount = widths.length;
break;
}
moreProbe.textContent = `${remaining} more...`;
const moreWidth = moreProbe.offsetWidth;
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
else break;
}
setVisibleCount(nextVisibleCount);
}
recomputeVisibleCount();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(recomputeVisibleCount);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}
window.addEventListener("resize", recomputeVisibleCount);
return () => window.removeEventListener("resize", recomputeVisibleCount);
}, [tags]);
if (!tags.length) {
return <span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>;
}
const visibleTags = tags.slice(0, visibleCount);
const hasOverflow = visibleCount < tags.length;
const remainingCount = tags.length - visibleCount;
return (
<div className="relative w-full">
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleTags.map((tag, index) => (
<span key={`${tag}-${index}`} className={TAG_CLASS}>
#{tag}
</span>
))}
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
</div>
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
{tags.map((tag, index) => (
<span
key={`${tag}-${index}`}
ref={element => {
tagRefs.current[index] = element;
}}
className={`${TAG_CLASS} inline-block`}
>
#{tag}
</span>
))}
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
{tags.length} more...
</span>
</div>
</div>
);
}
export default function EntriesList({
activeGroupId,
loading,
entries,
visibleEntries,
activeFilterCount,
onOpenDetails,
onClearFilters
}: EntriesListProps) {
return (
<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 ?? [];
return (
<div
key={entry.id}
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
onClick={() => onOpenDetails(entry, index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<NecessityIcon necessity={entry.necessity} />
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
</div>
<div className="text-xs text-muted">{new Date(entry.occurredAt).toISOString().slice(0, 10)}</div>
</div>
<EntryTagsRow tags={tags} />
</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={onClearFilters}>
Clear filters
</button>
) : null}
</div>
)
) : (
<div className="text-sm text-muted">No entries yet.</div>
)}
</div>
);
}

View File

@ -1,779 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useEntries from "@/features/entries/hooks/use-entries";
import useSchedules from "@/features/entries/hooks/use-schedules";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
import useTags from "@/features/tags/hooks/use-tags";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useUserSettings from "@/hooks/use-user-settings";
import ToggleButtonGroup from "@/components/toggle-button-group";
import NewEntryModal from "@/components/new-entry-modal";
import EntryDetailsModal from "@/components/entry-details-modal";
import NewScheduleModal, { type NewScheduleForm } from "@/components/new-schedule-modal";
import ScheduleDetailsModal, { type ScheduleDetailsForm } from "@/components/schedule-details-modal";
import EntriesList from "@/features/entries/components/entries-list";
import SchedulesList from "@/features/entries/components/schedules-list";
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
const EMPTY_FILTERS: EntriesFilters = {
amountMin: "",
amountMax: "",
dateFrom: "",
dateTo: "",
necessity: "ANY",
notesQuery: "",
tags: [],
tagsMode: "ANY"
};
function normalizeTagList(tags: string[]) {
return tags.map(tag => tag.toLowerCase()).sort().join("|");
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
function ListProgressSignal({
hasMore,
shownCount,
totalCount,
noun
}: {
hasMore: boolean;
shownCount: number;
totalCount: number;
noun: "entries" | "schedules";
}) {
if (totalCount <= 0) return null;
return (
<div
className={`mt-3 flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs ${hasMore ? "border-accent-weak bg-accent-soft text-[color:var(--color-text)]" : "border-accent-weak bg-panel text-soft"}`}
aria-live="polite"
>
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full border ${hasMore ? "border-accent bg-panel text-[color:var(--color-accent)]" : "border-accent-weak text-soft"}`}>
{hasMore ? "\u21e3" : "\u2713"}
</span>
<span>
{hasMore
? `Keep scrolling for more ${noun} (${shownCount} of ${totalCount})`
: `You have reached the end of ${noun} (${totalCount} total)`}
</span>
</div>
);
}
export default function EntriesPanel() {
const today = new Date().toISOString().slice(0, 10);
const { groups, activeGroupId } = useGroupsContext();
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
const { settings: userSettings } = useUserSettings();
const { notify } = useNotificationsContext();
const { notifyEntryMutation } = useEntryMutation();
const { tags: tagSuggestions } = useTags(activeGroupId);
const { settings: groupSettings } = useGroupSettings(activeGroupId);
const router = useRouter();
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage));
const emptyTagActionLabel = canManageTags
? "No Tags Assigned Yet - Click To Assign Tags"
: "No Tags Assigned Yet - Contact Your Group Admin";
const pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10));
const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES");
const [filters, setFilters] = useState<EntriesFilters>(EMPTY_FILTERS);
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
const [filterOpen, setFilterOpen] = useState(false);
const [newEntryOpen, setNewEntryOpen] = useState(false);
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY");
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
const [entryForm, setEntryForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME"
});
const [entryDetailsForm, setEntryDetailsForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME"
});
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<typeof entryDetailsForm | null>(null);
const [scheduleForm, setScheduleForm] = useState<NewScheduleForm>({
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
createEntryNow: false
});
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsForm>({
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
nextRunOn: today,
isActive: true
});
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsForm | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(null);
const entriesLoadSentinelRef = useRef<HTMLDivElement>(null);
const schedulesLoadSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}, [pageSize]);
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]);
const filteredEntries = useMemo(() => {
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;
const time = new Date(entry.occurredAt).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
if (query && !(entry.notes || "").toLowerCase().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 filteredSchedules = useMemo(() => {
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 schedules.filter(schedule => {
if (min != null && schedule.amountDollars < min) return false;
if (max != null && schedule.amountDollars > max) return false;
const time = new Date(schedule.startsOn).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false;
if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false;
if (tagsFilter.length) {
const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") {
if (!tagsFilter.every(tag => scheduleTags.includes(tag))) return false;
} else if (!tagsFilter.some(tag => scheduleTags.includes(tag))) return false;
}
return true;
});
}, [schedules, filters]);
const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]);
const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]);
const hasMoreEntries = filteredEntries.length > visibleEntries.length;
const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length;
useEffect(() => {
if (entryTab !== "ENTRIES" || !hasMoreEntries) return;
let touchY: number | null = null;
let lastLoadAt = 0;
let lastScrollY = window.scrollY;
function shouldLoadMore() {
const sentinel = entriesLoadSentinelRef.current;
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return rect.top <= window.innerHeight + 48;
}
function tryLoadMore() {
if (!shouldLoadMore()) return;
const now = Date.now();
if (now - lastLoadAt < 150) return;
lastLoadAt = now;
setEntryVisibleCount(prev => {
if (prev >= filteredEntries.length) return prev;
return Math.min(prev + pageSize, filteredEntries.length);
});
}
function onWheel(event: WheelEvent) {
if (event.deltaY <= 0) return;
tryLoadMore();
}
function onScroll() {
const nextY = window.scrollY;
if (nextY <= lastScrollY) {
lastScrollY = nextY;
return;
}
lastScrollY = nextY;
tryLoadMore();
}
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
tryLoadMore();
}
}
function onTouchStart(event: TouchEvent) {
touchY = event.touches[0]?.clientY ?? null;
}
function onTouchMove(event: TouchEvent) {
const nextY = event.touches[0]?.clientY;
if (touchY == null || nextY == null) return;
const delta = touchY - nextY;
touchY = nextY;
if (delta > 10) tryLoadMore();
}
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
};
}, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]);
useEffect(() => {
if (entryTab !== "SCHEDULES" || !hasMoreSchedules) return;
let touchY: number | null = null;
let lastLoadAt = 0;
let lastScrollY = window.scrollY;
function shouldLoadMore() {
const sentinel = schedulesLoadSentinelRef.current;
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return rect.top <= window.innerHeight + 48;
}
function tryLoadMore() {
if (!shouldLoadMore()) return;
const now = Date.now();
if (now - lastLoadAt < 150) return;
lastLoadAt = now;
setScheduleVisibleCount(prev => {
if (prev >= filteredSchedules.length) return prev;
return Math.min(prev + pageSize, filteredSchedules.length);
});
}
function onWheel(event: WheelEvent) {
if (event.deltaY <= 0) return;
tryLoadMore();
}
function onScroll() {
const nextY = window.scrollY;
if (nextY <= lastScrollY) {
lastScrollY = nextY;
return;
}
lastScrollY = nextY;
tryLoadMore();
}
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
tryLoadMore();
}
}
function onTouchStart(event: TouchEvent) {
touchY = event.touches[0]?.clientY ?? null;
}
function onTouchMove(event: TouchEvent) {
const nextY = event.touches[0]?.clientY;
if (touchY == null || nextY == null) return;
const delta = touchY - nextY;
touchY = nextY;
if (delta > 10) tryLoadMore();
}
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
};
}, [entryTab, hasMoreSchedules, filteredSchedules.length, pageSize]);
function clearFilters() {
setFilters(EMPTY_FILTERS);
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}
function handleEmptyTagAction() {
if (!canManageTags) return;
router.push("/groups/settings");
}
function hasEntryChanges() {
if (!entryDetailsOriginal) return false;
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
return (
entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars ||
entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt ||
entryDetailsForm.necessity !== entryDetailsOriginal.necessity ||
entryDetailsForm.notes !== entryDetailsOriginal.notes ||
entryDetailsForm.entryType !== entryDetailsOriginal.entryType ||
normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags)
);
}
function hasScheduleChanges() {
if (!scheduleDetailsOriginal) return false;
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
return (
scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars ||
scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn ||
scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity ||
scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes ||
scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType ||
scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency ||
scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount ||
scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition ||
scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount ||
scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate ||
scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn ||
scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive ||
normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags)
);
}
async function submitNewEntry(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!e.currentTarget.reportValidity()) return;
const amountDollars = Number(entryForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !entryForm.tags.length) return;
const created = await createEntry({
entryType: entryForm.entryType,
amountDollars,
occurredAt: entryForm.occurredAt,
necessity: entryForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType: entryForm.tags.join(", ") || "General",
notes: entryForm.notes.trim() || undefined,
tags: entryForm.tags
});
if (!created) return;
setNewEntryOpen(false);
setEntryForm({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING" });
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
notifyEntryMutation();
}
async function submitNewSchedule(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const amountDollars = Number(scheduleForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !scheduleForm.tags.length) return;
const created = await createSchedule({
entryType: scheduleForm.entryType,
amountDollars,
startsOn: scheduleForm.startsOn,
necessity: scheduleForm.necessity,
purchaseType: scheduleForm.tags.join(", ") || "General",
notes: scheduleForm.notes.trim() || undefined,
tags: scheduleForm.tags,
frequency: scheduleForm.frequency,
intervalCount: scheduleForm.intervalCount,
endCondition: scheduleForm.endCondition,
endCount: scheduleForm.endCondition === "AFTER_COUNT" ? Number(scheduleForm.endCount || 0) || null : null,
endDate: scheduleForm.endCondition === "BY_DATE" ? scheduleForm.endDate || null : null,
createEntryNow: scheduleForm.createEntryNow
});
if (!created) return;
setNewScheduleOpen(false);
setScheduleForm({ amountDollars: "", startsOn: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING", frequency: "MONTHLY", intervalCount: 1, endCondition: "NEVER", endCount: "", endDate: "", createEntryNow: false });
notify({ title: "Schedule added", message: `${created.tags.join(", ") || "Schedule"} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
if (scheduleForm.createEntryNow) notifyEntryMutation();
}
function openEntryDetails(id: number) {
const index = filteredEntries.findIndex(entry => Number(entry.id) === Number(id));
if (index < 0) return;
const entry = filteredEntries[index];
const form = { amountDollars: String(entry.amountDollars), occurredAt: entry.occurredAt, necessity: entry.necessity, notes: entry.notes || "", tags: entry.tags || [], entryType: entry.entryType };
setSelectedEntryId(Number(id));
setSelectedEntryIndex(index);
setEntryDetailsForm(form);
setEntryDetailsOriginal(form);
setEntryRemovedTags([]);
setEntryDetailsOpen(true);
}
function openScheduleDetails(id: number) {
const schedule = schedules.find(item => Number(item.id) === Number(id));
if (!schedule) return;
const form: ScheduleDetailsForm = {
amountDollars: String(schedule.amountDollars),
startsOn: schedule.startsOn,
necessity: schedule.necessity,
notes: schedule.notes || "",
tags: schedule.tags || [],
entryType: schedule.entryType,
frequency: schedule.frequency,
intervalCount: schedule.intervalCount,
endCondition: schedule.endCondition,
endCount: schedule.endCount == null ? "" : String(schedule.endCount),
endDate: schedule.endDate || "",
nextRunOn: schedule.nextRunOn,
isActive: schedule.isActive
};
setSelectedScheduleId(Number(id));
setScheduleDetailsForm(form);
setScheduleDetailsOriginal(form);
setScheduleRemovedTags([]);
setScheduleDetailsOpen(true);
}
async function submitEntryUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedEntryId || !hasEntryChanges()) return;
const amount = Number(entryDetailsForm.amountDollars || 0);
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateEntry({
id: selectedEntryId,
entryType: entryDetailsForm.entryType,
amountDollars: amount,
occurredAt: entryDetailsForm.occurredAt,
necessity: entryDetailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType: tags.join(", ") || "General",
notes: entryDetailsForm.notes.trim() || undefined,
tags
});
if (!updated) return;
setEntryDetailsOpen(false);
notify({ title: "Entry updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
notifyEntryMutation();
}
async function submitScheduleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedScheduleId || !hasScheduleChanges()) return;
const amount = Number(scheduleDetailsForm.amountDollars || 0);
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateSchedule({
id: selectedScheduleId,
entryType: scheduleDetailsForm.entryType,
amountDollars: amount,
startsOn: scheduleDetailsForm.startsOn,
necessity: scheduleDetailsForm.necessity,
purchaseType: tags.join(", ") || "General",
notes: scheduleDetailsForm.notes.trim() || undefined,
tags,
frequency: scheduleDetailsForm.frequency,
intervalCount: scheduleDetailsForm.intervalCount,
endCondition: scheduleDetailsForm.endCondition,
endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null,
endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null,
nextRunOn: scheduleDetailsForm.nextRunOn,
isActive: scheduleDetailsForm.isActive
});
if (!updated) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
}
async function confirmDelete() {
if (deleteTarget === "ENTRY" && selectedEntryId) {
const removed = await deleteEntry(selectedEntryId);
if (!removed) return;
setEntryDetailsOpen(false);
notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" });
notifyEntryMutation();
}
if (deleteTarget === "SCHEDULE" && selectedScheduleId) {
const removed = await deleteSchedule(selectedScheduleId);
if (!removed) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" });
}
}
function prevEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === 0 ? filteredEntries.length - 1 : current - 1;
openEntryDetails(filteredEntries[index].id);
}
function nextEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === filteredEntries.length - 1 ? 0 : current + 1;
openEntryDetails(filteredEntries[index].id);
}
return (
<>
<div className="space-y-4">
<div className="panel panel-accent p-4">
<div className="card-header">
<ToggleButtonGroup
value={entryTab}
onChange={setEntryTab}
ariaLabel="Entries and schedules tab"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
]}
/>
<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={() => entryTab === "ENTRIES" ? setNewEntryOpen(true) : setNewScheduleOpen(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={entryTab === "ENTRIES" ? "Add entry" : "Add schedule"}>
+
</button>
</div>
</div>
{entryTab === "ENTRIES" ? (
<>
<EntriesList
activeGroupId={activeGroupId}
loading={entriesLoading}
entries={entries}
visibleEntries={visibleEntries}
activeFilterCount={activeFilterCount}
onOpenDetails={entry => openEntryDetails(entry.id)}
onClearFilters={clearFilters}
/>
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
<ListProgressSignal
hasMore={hasMoreEntries}
shownCount={visibleEntries.length}
totalCount={filteredEntries.length}
noun="entries"
/>
</>
) : (
<>
<SchedulesList
activeGroupId={activeGroupId}
loading={schedulesLoading}
schedules={schedules}
visibleSchedules={visibleSchedules}
activeFilterCount={activeFilterCount}
onOpenDetails={schedule => openScheduleDetails(schedule.id)}
onClearFilters={clearFilters}
/>
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
<ListProgressSignal
hasMore={hasMoreSchedules}
shownCount={visibleSchedules.length}
totalCount={filteredSchedules.length}
noun="schedules"
/>
</>
)}
</div>
</div>
<NewEntryModal
isOpen={newEntryOpen && Boolean(activeGroupId)}
form={entryForm}
error={entriesError}
onClose={() => setNewEntryOpen(false)}
onSubmit={submitNewEntry}
onChange={next => setEntryForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
amountInputRef={amountInputRef}
tagsInputRef={tagsInputRef}
/>
<NewScheduleModal
isOpen={newScheduleOpen && Boolean(activeGroupId)}
form={scheduleForm}
error={schedulesError}
onClose={() => setNewScheduleOpen(false)}
onSubmit={submitNewSchedule}
onChange={next => setScheduleForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
/>
<EntryDetailsModal
isOpen={entryDetailsOpen}
form={entryDetailsForm}
originalForm={entryDetailsOriginal}
isDirty={hasEntryChanges()}
error={entriesError}
onClose={() => setEntryDetailsOpen(false)}
onSubmit={submitEntryUpdate}
onRequestDelete={() => {
setDeleteTarget("ENTRY");
setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!entryDetailsOriginal) return;
setEntryDetailsForm(entryDetailsOriginal);
setEntryRemovedTags([]);
}}
onChange={next => setEntryDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
setEntryDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
setEntryRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => setEntryRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
removedTags={entryRemovedTags}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
onPrev={prevEntry}
onNext={nextEntry}
loopHintPrev={selectedEntryIndex === 0 && filteredEntries.length > 1 ? "Loop" : ""}
loopHintNext={selectedEntryIndex === filteredEntries.length - 1 && filteredEntries.length > 1 ? "Loop" : ""}
canNavigate={filteredEntries.length > 1}
/>
<ScheduleDetailsModal
isOpen={scheduleDetailsOpen}
form={scheduleDetailsForm}
originalForm={scheduleDetailsOriginal}
isDirty={hasScheduleChanges()}
error={schedulesError}
onClose={() => setScheduleDetailsOpen(false)}
onSubmit={submitScheduleUpdate}
onRequestDelete={() => {
setDeleteTarget("SCHEDULE");
setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!scheduleDetailsOriginal) return;
setScheduleDetailsForm(scheduleDetailsOriginal);
setScheduleRemovedTags([]);
}}
onChange={next => setScheduleDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
setScheduleDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
setScheduleRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => setScheduleRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
removedTags={scheduleRemovedTags}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
/>
<EntriesFilterModal
isOpen={filterOpen}
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
tagSuggestions={tagSuggestions}
canManageTags={canManageTags}
emptyTagActionLabel={emptyTagActionLabel}
onEmptyTagAction={handleEmptyTagAction}
onClearFilters={clearFilters}
onFilterAddTag={tag => setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))}
onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))}
onClose={() => setFilterOpen(false)}
/>
<ConfirmSlideModal
isOpen={confirmDeleteOpen}
title={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
description={deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
confirmLabel={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => {
setConfirmDeleteOpen(false);
confirmDelete();
}}
/>
</>
);
}

View File

@ -1,85 +0,0 @@
"use client";
import type { Schedule } from "@/lib/shared/types";
type SchedulesListProps = {
activeGroupId: number | null;
loading: boolean;
schedules: Schedule[];
visibleSchedules: Schedule[];
activeFilterCount: number;
onOpenDetails: (schedule: Schedule, index: number) => void;
onClearFilters: () => void;
};
export default function SchedulesList({
activeGroupId,
loading,
schedules,
visibleSchedules,
activeFilterCount,
onOpenDetails,
onClearFilters
}: SchedulesListProps) {
return (
<div className="mt-3 space-y-2">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view schedules.</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="h-3 w-36 rounded bg-surface" />
</div>
</div>
))}
</div>
) : schedules.length ? (
visibleSchedules.length ? (
visibleSchedules.map((schedule, index) => (
<div
key={schedule.id}
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
onClick={() => onOpenDetails(schedule, index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="text-base font-semibold">${schedule.amountDollars.toFixed(2)}</div>
<div className={`rounded-full border px-2 py-0.5 text-[10px] ${schedule.isActive ? "border-green-400/60 bg-green-500/10 text-green-200" : "border-amber-400/60 bg-amber-500/10 text-amber-200"}`}>
{schedule.isActive ? "Active" : "Paused"}
</div>
</div>
<div className="text-xs text-muted">Next: {schedule.nextRunOn}</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{(schedule.tags || []).length ? (
schedule.tags.map(tag => (
<span key={`${schedule.id}-${tag}`} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
#{tag}
</span>
))
) : (
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
)}
</div>
</div>
))
) : (
<div className="space-y-2 text-sm text-muted">
<div>No matching schedules.</div>
{activeFilterCount ? (
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={onClearFilters}>
Clear filters
</button>
) : null}
</div>
)
) : (
<div className="text-sm text-muted">No schedules yet.</div>
)}
</div>
);
}

View File

@ -1,119 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
import { schedulesCreate, schedulesDelete, schedulesList, schedulesUpdate } from "@/lib/client/schedules";
import type { ApiResult } from "@/lib/client/fetch-json";
type ScheduleInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: ScheduleFrequency;
intervalCount?: number;
endCondition?: ScheduleEndCondition;
endCount?: number | null;
endDate?: string | null;
};
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
function compareSchedulesAsc(a: Schedule, b: Schedule) {
if (a.nextRunOn === b.nextRunOn) return Number(a.id) - Number(b.id);
return a.nextRunOn > b.nextRunOn ? 1 : -1;
}
function upsertScheduleSorted(schedules: Schedule[], next: Schedule) {
const without = schedules.filter(item => Number(item.id) !== Number(next.id));
const merged = [next, ...without];
merged.sort(compareSchedulesAsc);
return merged;
}
export default function useSchedules(activeGroupId?: number | null) {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
if (!activeGroupId) {
setError("");
setSchedules([]);
setLoading(false);
return;
}
setLoading(true);
setError("");
const result = await schedulesList();
if (isError(result)) {
setError(result.error.message || "");
setSchedules([]);
} else {
const next = [...(result.data.schedules || [])];
next.sort(compareSchedulesAsc);
setSchedules(next);
}
setLoading(false);
}, [activeGroupId]);
const createSchedule = useCallback(async (input: ScheduleInput & { createEntryNow?: boolean }): Promise<Schedule | null> => {
setError("");
const result = await schedulesCreate(input);
if (isError(result)) {
setError(result.error.message || "");
return null;
}
const created = result.data.schedule;
setSchedules(prev => upsertScheduleSorted(prev, created));
return created;
}, []);
const updateSchedule = useCallback(async (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }): Promise<Schedule | null> => {
setError("");
const result = await schedulesUpdate(input);
if (isError(result)) {
setError(result.error.message || "");
return null;
}
const updated = result.data.schedule;
setSchedules(prev => upsertScheduleSorted(prev, updated));
return updated;
}, []);
const deleteSchedule = useCallback(async (id: number | string): Promise<Schedule | null> => {
setError("");
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) return null;
let removed: Schedule | null = null;
const result = await schedulesDelete({ id });
if (isError(result)) {
setError(result.error.message || "");
return null;
}
setSchedules(prev => {
const index = prev.findIndex(item => Number(item.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 {
schedules,
loading,
error,
createSchedule,
updateSchedule,
deleteSchedule,
reload: load
};
}

View File

@ -1,3 +0,0 @@
# Groups Feature
Reserved for groups domain modules (components/hooks/lib) during incremental migration.

View File

@ -1,3 +0,0 @@
# Tags Feature
Reserved for tags domain modules (components/hooks/lib) during incremental migration.

View File

@ -1,7 +1,7 @@
"use client";
import { createContext, useContext } from "react";
import useAuth from "@/features/auth/hooks/use-auth";
import useAuth from "@/hooks/use-auth";
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
@ -19,4 +19,3 @@ export function useAuthContext() {
if (!ctx) throw new Error("AuthProvider is missing");
return ctx;
}

View File

@ -1,7 +1,7 @@
"use client";
import { createContext, useContext } from "react";
import useGroups from "@/features/groups/hooks/use-groups";
import useGroups from "@/hooks/use-groups";
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
@ -19,4 +19,3 @@ export function useGroupsContext() {
if (!ctx) throw new Error("GroupsProvider is missing");
return ctx;
}

View File

@ -12,6 +12,13 @@ type CreateEntryInput = {
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 };
@ -56,7 +63,7 @@ export default function useEntries(activeGroupId?: number | null) {
setLoading(false);
}, [activeGroupId]);
const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
const createEntry = useCallback(async (input: CreateEntryInput) => {
setError("");
const result = await entriesCreate(input);
if (isError(result)) {
@ -68,7 +75,7 @@ export default function useEntries(activeGroupId?: number | null) {
return created;
}, []);
const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
const updateEntry = useCallback(async (input: UpdateEntryInput) => {
setError("");
const result = await entriesUpdate(input);
if (isError(result)) {
@ -82,7 +89,7 @@ export default function useEntries(activeGroupId?: number | null) {
return updated;
}, []);
const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
const deleteEntry = useCallback(async (id: number | string) => {
setError("");
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) return null;

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