checkpoint: commit remaining in-progress refactor changes
This commit is contained in:
parent
1b7e4b94b5
commit
3ee1a87d58
28
.codex/config.toml
Normal file
28
.codex/config.toml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
148
.github/copilot-instructions.md
vendored
148
.github/copilot-instructions.md
vendored
@ -1,123 +1,41 @@
|
|||||||
# Copilot Instructions — Fiddy (External DB)
|
# Copilot Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## Source of truth
|
## Authority
|
||||||
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||||
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||||
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
|
- Keep this file short: it’s a guide for Copilot behavior, not the full spec.
|
||||||
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
|
|
||||||
|
|
||||||
## Stack
|
## High-level behavior
|
||||||
- Monorepo (npm workspaces)
|
- Make the **smallest change** that resolves the bug or request.
|
||||||
- Next.js (App Router) + TypeScript + Tailwind
|
- **Scan the repo first** for existing patterns (don’t invent files/endpoints unless necessary).
|
||||||
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
- Respect layering: **route → server service → client wrapper → hook → UI**.
|
||||||
- Docker Compose dev/prod
|
- Keep diffs tight; avoid large refactors unless required.
|
||||||
- Gitea + act-runner CI/CD
|
|
||||||
|
|
||||||
## Environment
|
## Hard rules (do not violate)
|
||||||
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
||||||
- `DATABASE_URL` points to the external DB server (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.
|
||||||
|
|
||||||
## Auth
|
## Architecture quick map (follow existing patterns)
|
||||||
- Custom email/password auth.
|
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
|
||||||
- Use HttpOnly session cookies backed by DB table `sessions`.
|
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
|
||||||
- NEVER trust client-side RBAC checks.
|
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
|
||||||
|
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
|
||||||
|
|
||||||
## Receipts
|
## API conventions
|
||||||
- Store receipt images in Postgres `bytea` table `receipts`.
|
- Prefer error shape: `{ error: { code, message }, request_id? }`
|
||||||
- Entries list endpoints must not return image bytes.
|
- Validate input at the route boundary; authorize in services.
|
||||||
- Image bytes only fetched by separate endpoint when inspecting a single item.
|
|
||||||
|
|
||||||
## UI
|
## 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
|
||||||
- Dark mode, minimal, mobile-first.
|
- Dark mode, minimal, mobile-first.
|
||||||
- Dodger Blue accent (#1E90FF).
|
- Navbar layout: left nav dropdown, middle group selector, right user menu.
|
||||||
- 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 there’s 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.
|
|
||||||
|
|||||||
44
AGENTS.md
Normal file
44
AGENTS.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# 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; don’t 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; don’t 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).
|
||||||
@ -1,9 +1,113 @@
|
|||||||
# Project Instructions — Fiddy (External DB)
|
# Project Instructions — Fiddy (External DB)
|
||||||
|
|
||||||
## Core expectation
|
## 1) Core expectation
|
||||||
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
|
||||||
|
|
||||||
## Decisions / constraints (Group Settings)
|
## 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`.
|
||||||
|
|
||||||
|
### 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)
|
||||||
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
- 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`.
|
- 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.
|
- Both owner and admins can approve join requests and manage invite links.
|
||||||
@ -15,10 +119,11 @@ This project connects to an external Postgres instance (on-prem server). Dev and
|
|||||||
- Single-use links are deleted after successful use.
|
- Single-use links are deleted after successful use.
|
||||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
- 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).
|
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||||
- API must generate `request_id` and return it in responses; audit logs must include it.
|
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||||
- Audit logs must never store full invite codes (store last4 only).
|
|
||||||
|
|
||||||
## Do first (vertical slice)
|
---
|
||||||
|
|
||||||
|
## 7) Do first (vertical slice)
|
||||||
1) DB migrate command + schema
|
1) DB migrate command + schema
|
||||||
2) Register/Login/Logout (custom sessions)
|
2) Register/Login/Logout (custom sessions)
|
||||||
3) Protected dashboard page
|
3) Protected dashboard page
|
||||||
@ -27,24 +132,35 @@ This project connects to an external Postgres instance (on-prem server). Dev and
|
|||||||
6) Receipt upload/download endpoints
|
6) Receipt upload/download endpoints
|
||||||
7) Settings + Reports
|
7) Settings + Reports
|
||||||
|
|
||||||
## Definition of done
|
---
|
||||||
- Works via docker-compose.dev.yml with external DB
|
|
||||||
|
## 8) Definition of done
|
||||||
|
- Works via `docker-compose.dev.yml` with external DB
|
||||||
- Migrations applied via `npm run db:migrate`
|
- Migrations applied via `npm run db:migrate`
|
||||||
- Tests + lint pass
|
- Tests + lint pass
|
||||||
- RBAC enforced server-side
|
- RBAC enforced server-side
|
||||||
- No large files
|
- No large files
|
||||||
- No TypeScript warnings or lint errors in touched files
|
- No TypeScript warnings or lint errors in touched files
|
||||||
- No new cron/worker dependencies unless explicitly approved
|
- 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
|
||||||
|
|
||||||
## Desktop + mobile UX checklist (required)
|
---
|
||||||
|
|
||||||
|
## 9) Desktop + mobile UX checklist (required)
|
||||||
- Touch: long-press affordance for item-level actions when no visible button.
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
- Mouse: hover affordance on interactive rows/cards.
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- Modal overlays must close on outside click/tap.
|
- Modal overlays must close on outside click/tap.
|
||||||
- Use bubble notifications for main actions (create/update/delete/join).
|
- Use bubble notifications for main actions (create/update/delete/join).
|
||||||
- Add Playwright UI tests for new UI features and critical flows.
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
|
||||||
|
|
||||||
## PR review checklist
|
---
|
||||||
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
|
|
||||||
- No TypeScript warnings or lint errors introduced.
|
## 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
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuthContext } from "@/hooks/auth-context";
|
import { useAuthContext } from "@/hooks/auth-context";
|
||||||
import useInviteLink from "@/hooks/use-invite-link";
|
import useInviteLink from "@/features/groups/hooks/use-invite-link";
|
||||||
|
|
||||||
export default function InvitePage() {
|
export default function InvitePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -154,7 +154,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Invite details</div>
|
<div className="card-title">Invite details</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-muted">Loading invite…</div>
|
<div className="text-sm text-muted">Loading invite...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-sm text-red-400">{error}</div>
|
<div className="text-sm text-red-400">{error}</div>
|
||||||
) : link ? (
|
) : link ? (
|
||||||
@ -203,7 +203,7 @@ export default function InvitePage() {
|
|||||||
<div className="card-title">Join this group</div>
|
<div className="card-title">Join this group</div>
|
||||||
</div>
|
</div>
|
||||||
{checkingSession ? (
|
{checkingSession ? (
|
||||||
<div className="text-sm text-muted">Checking session…</div>
|
<div className="text-sm text-muted">Checking session...</div>
|
||||||
) : !hasSession ? (
|
) : !hasSession ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
<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}
|
disabled={!link || Boolean(result) || accepting}
|
||||||
onClick={accept}
|
onClick={accept}
|
||||||
>
|
>
|
||||||
{accepting ? "Joining…" : actionLabel}
|
{accepting ? "Joining..." : actionLabel}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
@ -280,3 +280,4 @@ export default function InvitePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
apps/web/components/confirm-retype-modal.tsx
Normal file
92
apps/web/components/confirm-retype-modal.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import EntriesPanel from "@/components/entries-panel";
|
import EntriesPanel from "@/features/entries/components/entries-panel";
|
||||||
import BucketsPanel from "@/components/buckets-panel";
|
import BucketsPanel from "@/features/buckets/components/buckets-panel";
|
||||||
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
import { EntryMutationProvider } from "@/hooks/entry-mutation-context";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
apps/web/components/date-picker.tsx
Normal file
51
apps/web/components/date-picker.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
|
||||||
export type EntryDetailsForm = {
|
export type EntryDetailsForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -132,18 +134,18 @@ export default function EntryDetailsModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPrev}
|
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"
|
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}
|
disabled={!canNavigate}
|
||||||
aria-label="Previous entry"
|
aria-label="Previous entry"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
||||||
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
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"
|
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}
|
disabled={!canNavigate}
|
||||||
aria-label="Next entry"
|
aria-label="Next entry"
|
||||||
>
|
>
|
||||||
@ -163,7 +165,19 @@ export default function EntryDetailsModal({
|
|||||||
}}
|
}}
|
||||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex flex-wrap items-center gap-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
|
<button
|
||||||
type="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"}`}
|
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"}`}
|
||||||
@ -171,25 +185,14 @@ export default function EntryDetailsModal({
|
|||||||
title="Toggle Recurring Entry"
|
title="Toggle Recurring Entry"
|
||||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||||
>
|
>
|
||||||
<span aria-hidden>⟳</span>
|
<span aria-hidden className="font-bold text-[25px]"
|
||||||
</button>
|
style={{ transform: `translateY(-2px)` }}
|
||||||
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
|
>∞</span>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<label className="text-sm text-muted">
|
<label className="text-sm text-muted">
|
||||||
Amount ($)
|
Amount ($)
|
||||||
<input
|
<input
|
||||||
@ -204,32 +207,27 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<input
|
<DatePicker
|
||||||
name="occurredAt"
|
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}
|
value={form.occurredAt}
|
||||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
onChange={occurredAt => onChange({ occurredAt })}
|
||||||
required
|
required
|
||||||
|
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<div className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<TagInput
|
<TagInput
|
||||||
label="Tags"
|
label="Tags"
|
||||||
@ -269,22 +267,18 @@ export default function EntryDetailsModal({
|
|||||||
<option value="QUARTERLY">quarter(s)</option>
|
<option value="QUARTERLY">quarter(s)</option>
|
||||||
<option value="YEARLY">year(s)</option>
|
<option value="YEARLY">year(s)</option>
|
||||||
</select>
|
</select>
|
||||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
<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-2 text-xs font-semibold"
|
||||||
|
options={[
|
||||||
{ value: "NEVER", label: "Forever" },
|
{ value: "NEVER", label: "Forever" },
|
||||||
{ value: "BY_DATE", label: "Until" },
|
{ value: "BY_DATE", label: "Until" },
|
||||||
{ value: "AFTER_COUNT", label: "After" }
|
{ 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" ? (
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -296,26 +290,12 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{form.endCondition === "BY_DATE" ? (
|
{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`}>
|
<DatePicker
|
||||||
<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}
|
value={form.endDate}
|
||||||
onChange={e => onChange({ endDate: e.target.value })}
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
/>
|
/>
|
||||||
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -332,7 +312,7 @@ export default function EntryDetailsModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
<div className="md:col-span-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRevert}
|
onClick={onRevert}
|
||||||
@ -369,10 +349,11 @@ export default function EntryDetailsModal({
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="right rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
|
||||||
type BucketForm = {
|
type BucketForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -125,22 +126,18 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted md:col-span-2">
|
<div className="text-sm text-muted md:col-span-2">
|
||||||
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="text-sm text-muted md:col-span-2">
|
<label className="text-sm text-muted md:col-span-2">
|
||||||
Description
|
Description
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/components/tag-input";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
import DatePicker from "@/components/date-picker";
|
||||||
|
|
||||||
type NewEntryForm = {
|
type NewEntryForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
@ -54,17 +56,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [form.endDate, 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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -88,23 +79,17 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<div
|
<ToggleButtonGroup
|
||||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
value={form.entryType}
|
||||||
<button
|
onChange={entryType => onChange({ entryType })}
|
||||||
type="button"
|
ariaLabel="Entry type"
|
||||||
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
||||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||||
>
|
options={[
|
||||||
Spending
|
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
||||||
</button>
|
{ value: "INCOME", label: "Income" }
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -113,7 +98,9 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
title="Toggle Recurring Entry"
|
title="Toggle Recurring Entry"
|
||||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||||
>
|
>
|
||||||
<span aria-hidden>⟳</span>
|
<span aria-hidden className="font-bold text-[25px]"
|
||||||
|
style={{ transform: `translateY(-2px)` }}
|
||||||
|
>∞</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@ -148,38 +135,27 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-muted">
|
<div className="text-sm text-muted">
|
||||||
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
<DatePicker
|
||||||
<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"
|
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}
|
value={form.occurredAt}
|
||||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
onChange={occurredAt => onChange({ occurredAt })}
|
||||||
required
|
required
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
<div className="text-sm text-muted">
|
<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">
|
<ToggleButtonGroup
|
||||||
{([
|
value={form.necessity}
|
||||||
{ value: "NECESSARY", label: "Necessary" },
|
onChange={necessity => onChange({ necessity })}
|
||||||
{ value: "BOTH", label: "Both" },
|
ariaLabel="Necessity"
|
||||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||||
] as const).map(option => (
|
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||||
<button
|
options={[
|
||||||
key={option.value}
|
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||||
type="button"
|
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||||
onClick={() => onChange({ necessity: option.value })}
|
]}
|
||||||
>
|
/>
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TAGS */}
|
{/* TAGS */}
|
||||||
@ -200,17 +176,17 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
{/* RECURRING OPTIONS */}
|
{/* RECURRING OPTIONS */}
|
||||||
{form.isRecurring ? (
|
{form.isRecurring ? (
|
||||||
<div className="md:col-span-2">
|
<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-y-2">
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
<div className="text-sm text-muted mr-2">Every</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
className="w-14 input-base px-3 py-2 text-center text-sm"
|
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
|
||||||
value={form.intervalCount}
|
value={form.intervalCount}
|
||||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
|
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
|
||||||
value={form.frequency}
|
value={form.frequency}
|
||||||
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
||||||
>
|
>
|
||||||
@ -219,22 +195,18 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
<option value="MONTHLY">month(s)</option>
|
<option value="MONTHLY">month(s)</option>
|
||||||
<option value="YEARLY">year(s)</option>
|
<option value="YEARLY">year(s)</option>
|
||||||
</select>
|
</select>
|
||||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
<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: "NEVER", label: "Forever" },
|
||||||
{ value: "BY_DATE", label: "Until" },
|
{ value: "BY_DATE", label: "Until" },
|
||||||
{ value: "AFTER_COUNT", label: "After" }
|
{ 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" ? (
|
{form.endCondition === "AFTER_COUNT" ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -246,26 +218,12 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{form.endCondition === "BY_DATE" ? (
|
{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`}>
|
<DatePicker
|
||||||
<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}
|
value={form.endDate}
|
||||||
onChange={e => onChange({ endDate: e.target.value })}
|
onChange={endDate => onChange({ endDate })}
|
||||||
|
showWeekButtons={false}
|
||||||
|
centerInput
|
||||||
/>
|
/>
|
||||||
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -286,7 +244,7 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
|
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Add entry
|
{form.isRecurring ? "Set schedule and add entry" : "Add entry"}
|
||||||
</button>
|
</button>
|
||||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
77
apps/web/components/toggle-button-group.tsx
Normal file
77
apps/web/components/toggle-button-group.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ test("group dropdown lists seeded groups", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
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 expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Group settings" }).click();
|
await page.getByRole("button", { name: "Group settings" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||||
|
|
||||||
await expect(page.getByText("Join requests")).toBeVisible();
|
await expect(page.getByText("Join requests")).toBeVisible();
|
||||||
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
await expect(page.getByText("requester1@fiddy.dev")).toBeVisible();
|
||||||
|
|||||||
@ -6,17 +6,17 @@ test("seeded entries render with tags and no-tag state", async ({ page }) => {
|
|||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||||
await expect(page.getByText("$12.50")).toBeVisible();
|
await expect(page.getByText("$12.50").first()).toBeVisible();
|
||||||
await expect(page.getByText("#Food")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
||||||
await expect(page.getByText("#Travel")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
||||||
await expect(page.getByText("No tags")).toBeVisible();
|
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entry details modal opens", async ({ page }) => {
|
test("entry details modal opens", async ({ page }) => {
|
||||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
await page.getByText("$12.50").click();
|
await page.getByText("$12.50").first().click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close" }).click();
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||||
@ -26,13 +26,14 @@ test("empty tag callout shows contact admin for members", async ({ page }) => {
|
|||||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
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" });
|
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||||
await expect(callout).toBeVisible();
|
await expect(callout).toBeVisible();
|
||||||
await expect(callout).toBeDisabled();
|
await expect(callout).toBeDisabled();
|
||||||
@ -42,13 +43,14 @@ test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
|||||||
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|
||||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||||
await waitSetActive;
|
await waitSetActive;
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add entry" }).click();
|
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 page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||||
});
|
});
|
||||||
|
|||||||
9
apps/web/features/README.md
Normal file
9
apps/web/features/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 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.
|
||||||
3
apps/web/features/auth/README.md
Normal file
3
apps/web/features/auth/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Auth Feature
|
||||||
|
|
||||||
|
Reserved for auth domain modules (components/hooks/lib) during incremental migration.
|
||||||
@ -1,25 +1,17 @@
|
|||||||
import { Bucket } from "@/lib/server/buckets";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import type { Bucket } from "@/lib/client/buckets";
|
||||||
|
|
||||||
type BucketCardProps = {
|
type BucketCardProps = {
|
||||||
bucket: Bucket;
|
bucket: Bucket;
|
||||||
|
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
|
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
toggleExpanded: (bucketId: number) => void;
|
toggleExpanded: (bucketId: number) => void;
|
||||||
|
|
||||||
isMenuOpen: boolean;
|
isMenuOpen: boolean;
|
||||||
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
|
||||||
setConfirmDeleteId: (bucketId: number) => void;
|
setConfirmDeleteId: (bucketId: number) => void;
|
||||||
openEdit: (bucketId: number) => void;
|
openEdit: (bucketId: number) => void;
|
||||||
|
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
usageLabel: string;
|
usageLabel: string;
|
||||||
renderUsageBar: (bucket: Bucket) => React.ReactNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BucketCard({
|
export function BucketCard({
|
||||||
@ -33,8 +25,13 @@ export function BucketCard({
|
|||||||
openEdit,
|
openEdit,
|
||||||
limit,
|
limit,
|
||||||
usageLabel,
|
usageLabel,
|
||||||
renderUsageBar,
|
|
||||||
}: BucketCardProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
||||||
@ -42,12 +39,26 @@ export function BucketCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-start 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">
|
{limit > 0 ? (
|
||||||
{icon || "🚫"}
|
<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>
|
||||||
|
) : (
|
||||||
|
<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="min-w-0">
|
||||||
<div className="text-sm font-semibold truncate">{bucket.name}</div>
|
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
||||||
{bucket.description ? (
|
{bucket.description ? (
|
||||||
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
||||||
{bucket.description}
|
{bucket.description}
|
||||||
@ -67,7 +78,7 @@ export function BucketCard({
|
|||||||
aria-label="Bucket actions"
|
aria-label="Bucket actions"
|
||||||
data-bucket-menu-button
|
data-bucket-menu-button
|
||||||
>
|
>
|
||||||
⋯
|
...
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isMenuOpen ? (
|
{isMenuOpen ? (
|
||||||
@ -100,7 +111,6 @@ export function BucketCard({
|
|||||||
|
|
||||||
{limit > 0 ? (
|
{limit > 0 ? (
|
||||||
<>
|
<>
|
||||||
{renderUsageBar(bucket)}
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="mt-2 space-y-2 text-xs text-soft">
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||||
<div>{usageLabel}</div>
|
<div>{usageLabel}</div>
|
||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import useBuckets from "@/hooks/use-buckets";
|
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
||||||
import useTags from "@/hooks/use-tags";
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
import NewBucketModal from "@/components/new-bucket-modal";
|
import NewBucketModal from "@/components/new-bucket-modal";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
@ -121,27 +121,7 @@ export default function BucketsPanel() {
|
|||||||
function budgetUsage(bucket: typeof buckets[number]) {
|
function budgetUsage(bucket: typeof buckets[number]) {
|
||||||
const limit = bucket.budgetLimitDollars || 0;
|
const limit = bucket.budgetLimitDollars || 0;
|
||||||
const spent = bucket.totalUsage || 0;
|
const spent = bucket.totalUsage || 0;
|
||||||
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
return { limit, spent };
|
||||||
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 (
|
return (
|
||||||
@ -195,7 +175,6 @@ export default function BucketsPanel() {
|
|||||||
openEdit={openEdit}
|
openEdit={openEdit}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
usageLabel={usageLabel}
|
usageLabel={usageLabel}
|
||||||
renderUsageBar={renderUsageBar}
|
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
@ -228,3 +207,4 @@ export default function BucketsPanel() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/features/entries/components/entries-filter-modal.tsx
Normal file
185
apps/web/features/entries/components/entries-filter-modal.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/web/features/entries/components/entries-list.tsx
Normal file
107
apps/web/features/entries/components/entries-list.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
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={() => onOpenDetails(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={onClearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">No entries yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,22 +2,26 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import useEntries from "@/hooks/use-entries";
|
import useEntries from "@/features/entries/hooks/use-entries";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import NewEntryModal from "@/components/new-entry-modal";
|
import NewEntryModal from "@/components/new-entry-modal";
|
||||||
import EntryDetailsModal from "@/components/entry-details-modal";
|
import EntryDetailsModal from "@/components/entry-details-modal";
|
||||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||||
import useTags from "@/hooks/use-tags";
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||||
import useGroupSettings from "@/hooks/use-group-settings";
|
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
||||||
import TagInput from "@/components/tag-input";
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
|
import EntriesList from "@/features/entries/components/entries-list";
|
||||||
|
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
|
||||||
|
import EntriesDiscardModal from "@/features/entries/components/entries-discard-modal";
|
||||||
|
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||||
|
|
||||||
export default function EntriesPanel() {
|
export default function EntriesPanel() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const { groups, activeGroupId } = useGroupsContext();
|
const { groups, activeGroupId } = useGroupsContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||||
|
const { notifyEntryMutation } = useEntryMutation();
|
||||||
const { notify } = useNotificationsContext();
|
const { notify } = useNotificationsContext();
|
||||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
const { settings } = useGroupSettings(activeGroupId);
|
const { settings } = useGroupSettings(activeGroupId);
|
||||||
@ -64,7 +68,7 @@ export default function EntriesPanel() {
|
|||||||
const [discardOpen, setDiscardOpen] = useState(false);
|
const [discardOpen, setDiscardOpen] = useState(false);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
|
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
|
||||||
const emptyFilters = {
|
const emptyFilters: EntriesFilters = {
|
||||||
amountMin: "",
|
amountMin: "",
|
||||||
amountMax: "",
|
amountMax: "",
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
@ -210,10 +214,10 @@ export default function EntriesPanel() {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
notify({
|
notify({
|
||||||
title: "Entry added",
|
title: "Entry added",
|
||||||
message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`,
|
message: `${form.tags.join(", ")} - $${amountDollars.toFixed(2)}`,
|
||||||
tone: "success"
|
tone: "success"
|
||||||
});
|
});
|
||||||
emitEntryMutated({ before: null, after: createdEntry });
|
notifyEntryMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,9 +368,9 @@ export default function EntriesPanel() {
|
|||||||
setRemovedTags([]);
|
setRemovedTags([]);
|
||||||
notify({
|
notify({
|
||||||
title: "Entry updated",
|
title: "Entry updated",
|
||||||
message: `${nextTags.join(", ")} · $${amountDollars.toFixed(2)}`
|
message: `${nextTags.join(", ")} - $${amountDollars.toFixed(2)}`
|
||||||
});
|
});
|
||||||
emitEntryMutated({ before: beforeEntry, after: updatedEntry });
|
notifyEntryMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,7 +387,7 @@ export default function EntriesPanel() {
|
|||||||
message: detailsForm.tags.join(", ") || "Entry removed",
|
message: detailsForm.tags.join(", ") || "Entry removed",
|
||||||
tone: "danger"
|
tone: "danger"
|
||||||
});
|
});
|
||||||
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
|
notifyEntryMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,22 +439,16 @@ export default function EntriesPanel() {
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h2 className="card-title text-lg">Entries</h2>
|
<h2 className="card-title text-lg">Entries</h2>
|
||||||
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
|
<ToggleButtonGroup
|
||||||
<button
|
value={entryTab}
|
||||||
type="button"
|
onChange={setEntryTab}
|
||||||
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
|
ariaLabel="Entries tab"
|
||||||
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
|
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||||
>
|
options={[
|
||||||
Existing
|
{ value: "SINGLE", label: "Existing", className: "mr-[-10px] w-20" },
|
||||||
</button>
|
{ value: "RECURRING", label: "Scheduled", className: "w-20" }
|
||||||
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@ -472,87 +470,15 @@ export default function EntriesPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2">
|
<EntriesList
|
||||||
{!activeGroupId ? (
|
activeGroupId={activeGroupId}
|
||||||
<div className="text-sm text-muted">Select a group to view entries.</div>
|
loading={loading}
|
||||||
) : loading ? (
|
entries={entries}
|
||||||
<div className="space-y-2">
|
visibleEntries={visibleEntries}
|
||||||
{[0, 1, 2].map(row => (
|
activeFilterCount={activeFilterCount}
|
||||||
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
onOpenDetails={handleOpenDetails}
|
||||||
<div className="animate-pulse space-y-2">
|
onClearFilters={handleClearFilters}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<NewEntryModal
|
<NewEntryModal
|
||||||
@ -593,182 +519,25 @@ export default function EntriesPanel() {
|
|||||||
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
|
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
|
||||||
canNavigate={totalEntries > 1}
|
canNavigate={totalEntries > 1}
|
||||||
/>
|
/>
|
||||||
{filterOpen ? (
|
<EntriesFilterModal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
|
isOpen={filterOpen}
|
||||||
<div
|
filters={filters}
|
||||||
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
setFilters={setFilters}
|
||||||
onClick={event => event.stopPropagation()}
|
activeFilterCount={activeFilterCount}
|
||||||
onKeyDown={event => {
|
tagSuggestions={tagSuggestions}
|
||||||
if (event.key === "Escape") setFilterOpen(false);
|
canManageTags={canManageTags}
|
||||||
}}
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
role="dialog"
|
onEmptyTagAction={handleEmptyTagAction}
|
||||||
tabIndex={-1}
|
onClearFilters={handleClearFilters}
|
||||||
>
|
onFilterAddTag={handleFilterAddTag}
|
||||||
<div className="flex items-center justify-between">
|
onFilterToggleTag={handleFilterToggleTag}
|
||||||
<h2 className="text-lg font-semibold">Filter Entries</h2>
|
onClose={() => setFilterOpen(false)}
|
||||||
<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>
|
<EntriesDiscardModal
|
||||||
<input
|
isOpen={discardOpen}
|
||||||
type="number"
|
onCancel={handleCancelDiscard}
|
||||||
min={0}
|
onConfirm={handleConfirmDiscard}
|
||||||
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
|
<ConfirmSlideModal
|
||||||
isOpen={confirmDeleteOpen}
|
isOpen={confirmDeleteOpen}
|
||||||
title="Delete entry"
|
title="Delete entry"
|
||||||
@ -783,3 +552,5 @@ export default function EntriesPanel() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
3
apps/web/features/groups/README.md
Normal file
3
apps/web/features/groups/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Groups Feature
|
||||||
|
|
||||||
|
Reserved for groups domain modules (components/hooks/lib) during incremental migration.
|
||||||
3
apps/web/features/tags/README.md
Normal file
3
apps/web/features/tags/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Tags Feature
|
||||||
|
|
||||||
|
Reserved for tags domain modules (components/hooks/lib) during incremental migration.
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import useAuth from "@/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
|
|
||||||
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
|
const AuthContext = createContext<ReturnType<typeof useAuth> | null>(null);
|
||||||
|
|
||||||
@ -19,3 +19,4 @@ export function useAuthContext() {
|
|||||||
if (!ctx) throw new Error("AuthProvider is missing");
|
if (!ctx) throw new Error("AuthProvider is missing");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import useGroups from "@/hooks/use-groups";
|
import useGroups from "@/features/groups/hooks/use-groups";
|
||||||
|
|
||||||
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
|
const GroupsContext = createContext<ReturnType<typeof useGroups> | null>(null);
|
||||||
|
|
||||||
@ -19,3 +19,4 @@ export function useGroupsContext() {
|
|||||||
if (!ctx) throw new Error("GroupsProvider is missing");
|
if (!ctx) throw new Error("GroupsProvider is missing");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,99 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { Entry } from "@/lib/shared/types";
|
|
||||||
import { recurringEntriesCreate, recurringEntriesDelete, recurringEntriesList, recurringEntriesUpdate } from "@/lib/client/recurring-entries";
|
|
||||||
import type { ApiResult } from "@/lib/client/fetch-json";
|
|
||||||
|
|
||||||
type CreateRecurringEntryInput = {
|
|
||||||
entryType: "SPENDING" | "INCOME";
|
|
||||||
amountDollars: number;
|
|
||||||
occurredAt: string;
|
|
||||||
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
|
||||||
purchaseType: string;
|
|
||||||
notes?: string;
|
|
||||||
tags?: string[];
|
|
||||||
bucketId?: number | null;
|
|
||||||
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
||||||
intervalCount?: number;
|
|
||||||
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
||||||
endCount?: number | null;
|
|
||||||
endDate?: string | null;
|
|
||||||
nextRunAt?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UpdateRecurringEntryInput = CreateRecurringEntryInput & { id: number };
|
|
||||||
|
|
||||||
export default function useRecurringEntries(activeGroupId?: number | null) {
|
|
||||||
const [entries, setEntries] = useState<Entry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
|
|
||||||
return "error" in result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!activeGroupId) {
|
|
||||||
setError("");
|
|
||||||
setEntries([]);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
const result = await recurringEntriesList();
|
|
||||||
if (isError(result)) {
|
|
||||||
setError(result.error.message || "");
|
|
||||||
setEntries([]);
|
|
||||||
} else {
|
|
||||||
setEntries(result.data.entries || []);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [activeGroupId]);
|
|
||||||
|
|
||||||
const createEntry = useCallback(async (input: CreateRecurringEntryInput) => {
|
|
||||||
setError("");
|
|
||||||
const result = await recurringEntriesCreate(input);
|
|
||||||
if (isError(result)) {
|
|
||||||
setError(result.error.message || "");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
return true;
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const updateEntry = useCallback(async (input: UpdateRecurringEntryInput) => {
|
|
||||||
setError("");
|
|
||||||
const result = await recurringEntriesUpdate(input);
|
|
||||||
if (isError(result)) {
|
|
||||||
setError(result.error.message || "");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
return true;
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const deleteEntry = useCallback(async (id: number | string) => {
|
|
||||||
setError("");
|
|
||||||
const result = await recurringEntriesDelete({ id });
|
|
||||||
if (isError(result)) {
|
|
||||||
setError(result.error.message || "");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
return true;
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
entries,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
createEntry,
|
|
||||||
updateEntry,
|
|
||||||
deleteEntry,
|
|
||||||
reload: load
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import type { Entry } from "@/lib/shared/types";
|
|
||||||
|
|
||||||
export const ENTRY_MUTATED_EVENT = "fiddy:entry-mutated";
|
|
||||||
|
|
||||||
export type EntryMutationDetail = {
|
|
||||||
before: Entry | null;
|
|
||||||
after: Entry | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function emitEntryMutated(detail: EntryMutationDetail) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.dispatchEvent(new CustomEvent<EntryMutationDetail>(ENTRY_MUTATED_EVENT, { detail }));
|
|
||||||
}
|
|
||||||
@ -92,10 +92,12 @@ export function toErrorResponse(e: unknown, route: string, requestId?: string) {
|
|||||||
status = mapped?.status || 500;
|
status = mapped?.status || 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseContext = payload.context || {};
|
||||||
|
const logContext = { ...baseContext, route, requestId, status };
|
||||||
|
logApiError({ code: payload.code, message: payload.message, context: logContext });
|
||||||
|
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
const context = payload.context || {};
|
payload = { ...payload, context: redactContext(logContext) };
|
||||||
payload = { ...payload, context: { ...context, route } };
|
|
||||||
logApiError(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
if (process.env.NODE_ENV !== "test")
|
if (process.env.NODE_ENV !== "test")
|
||||||
require("server-only");
|
require("server-only");
|
||||||
|
import crypto from "node:crypto";
|
||||||
import getPool from "@/lib/server/db";
|
import getPool from "@/lib/server/db";
|
||||||
import { apiError } from "@/lib/server/errors";
|
import { apiError } from "@/lib/server/errors";
|
||||||
|
|
||||||
@ -11,6 +12,7 @@ type LimitInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let ensureTablePromise: Promise<void> | null = null;
|
let ensureTablePromise: Promise<void> | null = null;
|
||||||
|
let lastCleanupAtMs = 0;
|
||||||
|
|
||||||
async function ensureRateLimitsTable() {
|
async function ensureRateLimitsTable() {
|
||||||
if (!ensureTablePromise) {
|
if (!ensureTablePromise) {
|
||||||
@ -30,6 +32,22 @@ async function ensureRateLimitsTable() {
|
|||||||
await ensureTablePromise;
|
await ensureTablePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSegment(value: string, fallbackUnknown = true) {
|
||||||
|
const trimmed = value.trim().toLowerCase().slice(0, 256);
|
||||||
|
if (!trimmed) return fallbackUnknown ? "unknown" : "";
|
||||||
|
const safe = trimmed.replace(/[^a-z0-9:._-]/g, "_");
|
||||||
|
if (safe.length <= 96) return safe;
|
||||||
|
return `sha256:${crypto.createHash("sha256").update(safe).digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupStaleRateLimits() {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (nowMs - lastCleanupAtMs < 10 * 60 * 1000) return;
|
||||||
|
lastCleanupAtMs = nowMs;
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("delete from rate_limits where updated_at < now() - interval '2 days'");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWindowStart(nowMs: number, windowMs: number) {
|
function normalizeWindowStart(nowMs: number, windowMs: number) {
|
||||||
const bucketStart = Math.floor(nowMs / windowMs) * windowMs;
|
const bucketStart = Math.floor(nowMs / windowMs) * windowMs;
|
||||||
return new Date(bucketStart);
|
return new Date(bucketStart);
|
||||||
@ -37,6 +55,7 @@ function normalizeWindowStart(nowMs: number, windowMs: number) {
|
|||||||
|
|
||||||
async function consumeRateLimit(input: LimitInput) {
|
async function consumeRateLimit(input: LimitInput) {
|
||||||
await ensureRateLimitsTable();
|
await ensureRateLimitsTable();
|
||||||
|
await cleanupStaleRateLimits();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowStart = normalizeWindowStart(now, input.windowMs);
|
const windowStart = normalizeWindowStart(now, input.windowMs);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@ -74,8 +93,8 @@ export async function enforceAuthRateLimit(input: {
|
|||||||
identifierLimit?: number;
|
identifierLimit?: number;
|
||||||
windowMs?: number;
|
windowMs?: number;
|
||||||
}) {
|
}) {
|
||||||
const scope = `auth:${input.route}`;
|
const scope = normalizeSegment(`auth:${input.route}`);
|
||||||
const ip = String(input.ip || "unknown").trim().toLowerCase();
|
const ip = normalizeSegment(String(input.ip || "unknown"));
|
||||||
const windowMs = input.windowMs ?? (15 * 60 * 1000);
|
const windowMs = input.windowMs ?? (15 * 60 * 1000);
|
||||||
await consumeRateLimit({
|
await consumeRateLimit({
|
||||||
key: `${scope}:ip:${ip}`,
|
key: `${scope}:ip:${ip}`,
|
||||||
@ -84,7 +103,7 @@ export async function enforceAuthRateLimit(input: {
|
|||||||
windowMs
|
windowMs
|
||||||
});
|
});
|
||||||
|
|
||||||
const identifier = String(input.identifier || "").trim().toLowerCase();
|
const identifier = normalizeSegment(String(input.identifier || ""), false);
|
||||||
if (identifier) {
|
if (identifier) {
|
||||||
await consumeRateLimit({
|
await consumeRateLimit({
|
||||||
key: `${scope}:identifier:${identifier}`,
|
key: `${scope}:identifier:${identifier}`,
|
||||||
@ -96,9 +115,10 @@ export async function enforceAuthRateLimit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function enforceUserWriteRateLimit(input: { userId: number; scope: string; limit?: number; windowMs?: number }) {
|
export async function enforceUserWriteRateLimit(input: { userId: number; scope: string; limit?: number; windowMs?: number }) {
|
||||||
|
const scope = normalizeSegment(input.scope);
|
||||||
await consumeRateLimit({
|
await consumeRateLimit({
|
||||||
key: `write:user:${input.userId}:scope:${input.scope}`,
|
key: `write:user:${input.userId}:scope:${scope}`,
|
||||||
scope: input.scope,
|
scope,
|
||||||
limit: input.limit ?? 120,
|
limit: input.limit ?? 120,
|
||||||
windowMs: input.windowMs ?? (15 * 60 * 1000)
|
windowMs: input.windowMs ?? (15 * 60 * 1000)
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,11 +3,21 @@ if (process.env.NODE_ENV !== "test")
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { createRequestId } from "@/lib/server/errors";
|
import { createRequestId } from "@/lib/server/errors";
|
||||||
|
|
||||||
|
function parseForwardedIp(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const first = value.split(",")[0]?.trim();
|
||||||
|
if (!first) return null;
|
||||||
|
return first.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRequestMeta() {
|
export async function getRequestMeta() {
|
||||||
const headerStore = await headers();
|
const headerStore = await headers();
|
||||||
|
const forwardedRequestId = headerStore.get("x-request-id")?.trim();
|
||||||
|
const requestId = forwardedRequestId || createRequestId();
|
||||||
|
const ip = parseForwardedIp(headerStore.get("x-forwarded-for")) || parseForwardedIp(headerStore.get("x-real-ip"));
|
||||||
return {
|
return {
|
||||||
requestId: createRequestId(),
|
requestId,
|
||||||
ip: headerStore.get("x-forwarded-for") || headerStore.get("x-real-ip"),
|
ip,
|
||||||
userAgent: headerStore.get("user-agent")
|
userAgent: headerStore.get("user-agent")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
5
apps/web/shared/README.md
Normal file
5
apps/web/shared/README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Shared
|
||||||
|
|
||||||
|
Cross-domain reusable primitives only.
|
||||||
|
|
||||||
|
Use this for generic components/hooks/lib that are not tied to a single business domain.
|
||||||
@ -1,7 +1,12 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
content: [
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./features/**/*.{ts,tsx}",
|
||||||
|
"./shared/**/*.{ts,tsx}"
|
||||||
|
],
|
||||||
theme: { extend: {} },
|
theme: { extend: {} },
|
||||||
plugins: []
|
plugins: []
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@ -35,6 +35,7 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Request-Id $request_id always;
|
||||||
|
|
||||||
location /api/auth/login {
|
location /api/auth/login {
|
||||||
limit_req zone=fiddy_auth burst=15 nodelay;
|
limit_req zone=fiddy_auth burst=15 nodelay;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ proxy_set_header Host $host;
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
|
|||||||
177
docs/03_REFACTOR_1.md
Normal file
177
docs/03_REFACTOR_1.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Refactor 1 - Domain-First Folder Structure
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Improve reviewability and long-term maintainability by moving from mostly flat frontend folders to a domain-first structure with clear ownership and lower coupling.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Frontend structure in `apps/web`
|
||||||
|
- Instruction consistency updates
|
||||||
|
- Incremental migration with compatibility shims
|
||||||
|
- No behavior changes intended during structure migration
|
||||||
|
|
||||||
|
## Source Of Truth And Alignment
|
||||||
|
Use this precedence order when guidance conflicts:
|
||||||
|
1. `PROJECT_INSTRUCTIONS.md` (repo-level source of truth)
|
||||||
|
2. `.github/copilot-instructions.md` (architecture and coding rules)
|
||||||
|
3. `docs/03_REFACTOR_1.md` (this execution tracker and decisions for Refactor 1)
|
||||||
|
|
||||||
|
Current refactor-alignment decisions:
|
||||||
|
- Canonical entry->bucket mutation mechanism: `EntryMutationContext`
|
||||||
|
- Domain-first target layout: `apps/web/features/<domain>/...` and `apps/web/shared/...`
|
||||||
|
- Compatibility re-exports removed on 2026-02-12; new imports must use feature-owned paths
|
||||||
|
|
||||||
|
## Context Compression Guardrails
|
||||||
|
- At the start of each new session, read this file and the two source-of-truth docs above.
|
||||||
|
- Before implementation, re-state active decisions from "Current refactor-alignment decisions".
|
||||||
|
- Add noteworthy changes to "Notes Log" immediately when discovered.
|
||||||
|
- Do not introduce a second mechanism for any existing concern without logging a decision here first.
|
||||||
|
|
||||||
|
## Phase 0 - Architecture Alignment (single mechanism rule)
|
||||||
|
- [x] Decide one canonical entry->bucket mutation mechanism (`context` OR `event`)
|
||||||
|
- [x] Remove the non-canonical mechanism and all references
|
||||||
|
- [x] Verify there is no duplicate refresh trigger path
|
||||||
|
- [x] Document chosen mechanism in instructions
|
||||||
|
|
||||||
|
## Phase 1 - Instruction and Governance Updates
|
||||||
|
- [x] Update `.github/copilot-instructions.md`
|
||||||
|
- [x] Update `PROJECT_INSTRUCTIONS.md`
|
||||||
|
- [x] Add "single mechanism per concern" rule
|
||||||
|
- [x] Add file-size/complexity refactor thresholds
|
||||||
|
- [x] Add dead-path cleanup rule
|
||||||
|
- [x] Add architecture-consistency checklist to PR expectations
|
||||||
|
|
||||||
|
## Phase 2 - Folder Structure Scaffolding
|
||||||
|
- [x] Create `apps/web/features/`
|
||||||
|
- [x] Create `apps/web/shared/`
|
||||||
|
- [x] Define domain folders:
|
||||||
|
- [x] `features/entries`
|
||||||
|
- [x] `features/buckets`
|
||||||
|
- [x] `features/groups`
|
||||||
|
- [x] `features/tags`
|
||||||
|
- [x] `features/auth`
|
||||||
|
- [x] Add minimal README/tree notes in new folders (optional but recommended)
|
||||||
|
|
||||||
|
## Phase 3 - Incremental Domain Migration
|
||||||
|
### Entries + Buckets first (highest coupling)
|
||||||
|
- [x] Move entries UI/components into `features/entries/components`
|
||||||
|
- [x] Move buckets UI/components into `features/buckets/components`
|
||||||
|
- [x] Move domain hooks into `features/<domain>/hooks` (or keep centralized with strict naming, pick one and stay consistent)
|
||||||
|
- [x] Add temporary compatibility re-exports from old paths
|
||||||
|
|
||||||
|
### Remaining domains
|
||||||
|
- [x] Migrate groups
|
||||||
|
- [x] Migrate tags
|
||||||
|
- [x] Migrate auth
|
||||||
|
|
||||||
|
## Phase 4 - Import and Boundary Cleanup
|
||||||
|
- [x] Replace legacy imports with new feature paths
|
||||||
|
- [x] Ensure container vs presentational separation in large panels
|
||||||
|
- [x] Remove obsolete compatibility re-exports once import migration is complete
|
||||||
|
- [x] Remove dead files and stale helpers
|
||||||
|
|
||||||
|
## Phase 5 - Validation and Non-Regression
|
||||||
|
- [x] Run typecheck
|
||||||
|
- [ ] Run lint
|
||||||
|
- [x] Run relevant unit tests
|
||||||
|
- [ ] Smoke-test key UI flows:
|
||||||
|
- [ ] Entry create/update/delete
|
||||||
|
- [ ] Bucket usage updates
|
||||||
|
- [ ] Edit modal discard behavior
|
||||||
|
- [ ] Group switch behavior
|
||||||
|
|
||||||
|
## Phase 6 - Consolidation
|
||||||
|
- [ ] Publish final structure map in docs
|
||||||
|
- [ ] Confirm all checklist items closed
|
||||||
|
- [ ] Capture lessons learned and follow-up actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes Log (append as we discover issues)
|
||||||
|
- [YYYY-MM-DD] Observation:
|
||||||
|
- Context:
|
||||||
|
- Impact:
|
||||||
|
- Decision:
|
||||||
|
- Follow-up:
|
||||||
|
- [YYYY-MM-DD] Alignment Snapshot:
|
||||||
|
- Active mechanism:
|
||||||
|
- Active folder strategy:
|
||||||
|
- Remaining migration risk:
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Entry->bucket mutation flow had architectural drift (context + custom event mechanism both present).
|
||||||
|
- Impact: Higher cognitive load and harder debugging/review for regressions.
|
||||||
|
- Decision: Standardized on `EntryMutationContext`; removed `entry-mutation-events`.
|
||||||
|
- Follow-up: Keep scanning for duplicate state propagation patterns during domain migration.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: During migration edits, a literal `` `r`n `` sequence was accidentally inserted in `entries-panel.tsx`.
|
||||||
|
- Impact: Broke readability and could have caused compile/runtime issues.
|
||||||
|
- Decision: Fixed immediately and added note to keep encoding/text clean during scripted edits.
|
||||||
|
- Follow-up: Keep post-edit grep checks for suspicious escaped literals.
|
||||||
|
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: `npx tsc --noEmit` still fails on pre-existing server/test typing issues unrelated to this refactor slice.
|
||||||
|
- Impact: Full type-green gating cannot yet be used as a migration stop condition.
|
||||||
|
- Decision: Track failures as known baseline until addressed in separate debt fixes.
|
||||||
|
- Follow-up: Re-run typecheck after each migration phase and confirm no net-new errors.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Alignment details were spread across multiple docs and easy to miss after context compression.
|
||||||
|
- Impact: Higher risk of inconsistent implementation choices between sessions.
|
||||||
|
- Decision: Added explicit source-of-truth precedence and context-compression guardrails in this file.
|
||||||
|
- Follow-up: Keep the "Current refactor-alignment decisions" block updated whenever a major choice changes.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Hook migration started with entries and buckets domains.
|
||||||
|
- Impact: Domain ownership is clearer; old import paths still function via compatibility re-exports.
|
||||||
|
- Decision: Keep compatibility exports in `apps/web/hooks` until remaining domains are migrated.
|
||||||
|
- Follow-up: Migrate groups/tags/auth hooks and then remove legacy re-exports.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Migrated auth/groups/tags hooks into `apps/web/features/*/hooks` and converted legacy `apps/web/hooks/use-*.ts` files to compatibility re-exports.
|
||||||
|
- Impact: Domain ownership is now explicit while existing imports remain functional during the transition.
|
||||||
|
- Decision: Keep compatibility shims until Phase 4 import cleanup is complete.
|
||||||
|
- Follow-up: Remove compatibility shims in Phase 4 after all legacy component path re-exports are retired.
|
||||||
|
- [2026-02-12] Alignment Snapshot:
|
||||||
|
- Active mechanism: `EntryMutationContext`
|
||||||
|
- Active folder strategy: domain-first features (`apps/web/features/<domain>/{components,hooks}`)
|
||||||
|
- Remaining migration risk: leftover compatibility imports and large mixed-responsibility panels.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Replaced the final app-level `@/hooks/use-*` import (`apps/web/components/recurring-entries-panel.tsx`) with a feature hook import.
|
||||||
|
- Impact: Runtime code now reads hooks from feature-owned locations; `apps/web/hooks/use-*.ts` are compatibility shims only.
|
||||||
|
- Decision: Keep shims temporarily for migration safety.
|
||||||
|
- Follow-up: Delete hook shims during Phase 4 once no external references remain.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: PowerShell write operations introduced BOM and mojibake artifacts in a few migrated files.
|
||||||
|
- Impact: Increased review noise and risk of accidental text regressions.
|
||||||
|
- Decision: Normalized touched files to UTF-8 without BOM and replaced unstable glyphs with ASCII-safe equivalents.
|
||||||
|
- Follow-up: Prefer no-BOM file writes and run a quick mojibake scan after scripted edits.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Removed obsolete compatibility re-export files under `apps/web/components` and `apps/web/hooks/use-*.ts`.
|
||||||
|
- Impact: Reduced duplicate paths and eliminated stale import targets.
|
||||||
|
- Decision: Treat `apps/web/features/*` as the only valid home for migrated component/hook implementations.
|
||||||
|
- Follow-up: Continue Phase 4 by splitting large panels and removing any remaining dead paths.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: `apps/web/components/recurring-entries-panel.tsx` and `apps/web/hooks/use-recurring-entries.ts` had no references and were removed.
|
||||||
|
- Impact: Fewer dead maintenance paths and clearer active surface area.
|
||||||
|
- Decision: Keep recurring-entry behavior in server/client domain modules only until a routed UI is reintroduced.
|
||||||
|
- Follow-up: If recurring UI returns, add it directly under `features/entries/components`.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Lint validation is currently blocked because `npm run lint` calls `next lint` (invalid for current Next setup) and direct `eslint` has no config file.
|
||||||
|
- Impact: Lint cannot be used yet as a gating signal in this repo state.
|
||||||
|
- Decision: Keep lint checklist unchecked and treat as tooling debt.
|
||||||
|
- Follow-up: Add/restore ESLint config and update lint script before enforcing lint gates.
|
||||||
|
- [2026-02-12] Observation:
|
||||||
|
- Context: Split `features/entries/components/entries-panel.tsx` by extracting `EntriesList`, `EntriesFilterModal`, and `EntriesDiscardModal`.
|
||||||
|
- Impact: Smaller container panel with clearer behavior vs presentation boundaries and simpler review scope.
|
||||||
|
- Decision: Keep stateful orchestration in `EntriesPanel` and move UI-heavy rendering to dedicated presentational components.
|
||||||
|
- Follow-up: Apply the same split pattern to other oversized panels (notably group settings) in a later pass.
|
||||||
|
## Known Notes (seed)
|
||||||
|
- [x] Entry->bucket update mechanism currently shows architectural drift (context and event patterns both present in codebase references).
|
||||||
|
- [x] `entries-panel.tsx` remains large and should be split during migration for reviewability.
|
||||||
|
- [ ] Preserve behavior while moving files; avoid functional changes in structure-only PRs.
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
- Single, documented mutation propagation mechanism in use.
|
||||||
|
- Domain-first structure in place and imports migrated.
|
||||||
|
- No stale paths or duplicate mechanisms left.
|
||||||
|
- Validation checks pass (or known pre-existing failures documented separately).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -81,7 +81,24 @@ Primary outcomes:
|
|||||||
- `npm test`: pass (`25 passed`, `1 skipped`).
|
- `npm test`: pass (`25 passed`, `1 skipped`).
|
||||||
- `npm run build`: pass.
|
- `npm run build`: pass.
|
||||||
- `npm run lint`: pass (warnings only; no errors).
|
- `npm run lint`: pass (warnings only; no errors).
|
||||||
|
- Added request metadata hardening in `apps/web/lib/server/request.ts`:
|
||||||
|
- Use upstream `x-request-id` when present (fallback to generated ID).
|
||||||
|
- Parse first hop from forwarded IP headers and cap length.
|
||||||
|
- Added rate-limit hardening in `apps/web/lib/server/rate-limit.ts`:
|
||||||
|
- Sanitize and bound key segments to reduce key-space abuse.
|
||||||
|
- Hash oversized segments with SHA-256.
|
||||||
|
- Add opportunistic stale-row cleanup (older than 2 days) every 10 minutes per process.
|
||||||
|
- Added production-safe structured API error logging in `apps/web/lib/server/errors.ts`:
|
||||||
|
- Always emit `API_ERROR` with `requestId`, route, status, and sanitized context.
|
||||||
|
- Keep optional debug response context redacted before returning to clients.
|
||||||
|
- Added proxy request-id propagation:
|
||||||
|
- `docker/nginx/includes/fiddy-proxy.conf` now forwards `X-Request-Id`.
|
||||||
|
- `docker/nginx/fiddy.conf` now returns `X-Request-Id` response header.
|
||||||
|
- Re-validated after this hardening slice:
|
||||||
|
- `npm run lint`: pass (warnings only; no errors).
|
||||||
|
- `npm test`: pass (`25 passed`, `1 skipped`).
|
||||||
|
- `npm run build`: pass.
|
||||||
|
|
||||||
### Risks / Notes to Revisit
|
### Risks / Notes to Revisit
|
||||||
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
||||||
- `npm run lint` currently fails due `next lint` invocation behavior in this environment; lint verification needs explicit follow-up task.
|
- This Codex session currently cannot write to `.git` (index lock permission denied), so local user-side commits are required for newly staged changes.
|
||||||
|
|||||||
@ -30,8 +30,8 @@ Helpers:
|
|||||||
- `apps/web/app/api/entries/*`
|
- `apps/web/app/api/entries/*`
|
||||||
- `apps/web/lib/server/entries.ts`
|
- `apps/web/lib/server/entries.ts`
|
||||||
- `apps/web/lib/client/entries.ts`
|
- `apps/web/lib/client/entries.ts`
|
||||||
- `apps/web/hooks/use-entries.ts`
|
- `apps/web/features/entries/hooks/use-entries.ts`
|
||||||
- `apps/web/components/entries-panel.tsx`
|
- `apps/web/features/entries/components/entries-panel.tsx`
|
||||||
- `apps/web/app/page.tsx`
|
- `apps/web/app/page.tsx`
|
||||||
- `apps/web/__tests__/entries.test.ts`
|
- `apps/web/__tests__/entries.test.ts`
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,10 @@ Move API calls into exported hooks/services for reuse and cleanliness.
|
|||||||
- Update copilot instructions to prefer hooks/services for API calls.
|
- Update copilot instructions to prefer hooks/services for API calls.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
- `apps/web/hooks/use-entries.ts`
|
- `apps/web/features/entries/hooks/use-entries.ts`
|
||||||
- `apps/web/hooks/use-auth.ts`
|
- `apps/web/features/auth/hooks/use-auth.ts`
|
||||||
- `apps/web/hooks/use-groups.ts`
|
- `apps/web/features/groups/hooks/use-groups.ts`
|
||||||
- `apps/web/components/entries-panel.tsx`
|
- `apps/web/features/entries/components/entries-panel.tsx`
|
||||||
- `apps/web/components/navbar.tsx`
|
- `apps/web/components/navbar.tsx`
|
||||||
- `apps/web/app/login/page.tsx`
|
- `apps/web/app/login/page.tsx`
|
||||||
- `apps/web/app/register/page.tsx`
|
- `apps/web/app/register/page.tsx`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user