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)
|
||||
|
||||
## Source of truth
|
||||
- Always consult PROJECT_INSTRUCTIONS.md at the repo root.
|
||||
- If any guidance conflicts, PROJECT_INSTRUCTIONS.md takes precedence.
|
||||
- Keep this file focused on architecture/stack; keep checklists and project workflow rules in PROJECT_INSTRUCTIONS.md.
|
||||
- When asked to fix bugs, follow DEBUGGING_INSTRUCTIONS.md at the repo root.
|
||||
## Authority
|
||||
- **Source of truth:** `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||
- **Bugfix work:** follow `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||
- Keep this file short: it’s a guide for Copilot behavior, not the full spec.
|
||||
|
||||
## Stack
|
||||
- Monorepo (npm workspaces)
|
||||
- Next.js (App Router) + TypeScript + Tailwind
|
||||
- External Postgres (on-prem server) via node-postgres (pg). No ORM.
|
||||
- Docker Compose dev/prod
|
||||
- Gitea + act-runner CI/CD
|
||||
## High-level behavior
|
||||
- Make the **smallest change** that resolves the bug or request.
|
||||
- **Scan the repo first** for existing patterns (don’t invent files/endpoints unless necessary).
|
||||
- Respect layering: **route → server service → client wrapper → hook → UI**.
|
||||
- Keep diffs tight; avoid large refactors unless required.
|
||||
|
||||
## Environment
|
||||
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
|
||||
- `DATABASE_URL` points to the external DB server (NOT a container).
|
||||
## Hard rules (do not violate)
|
||||
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
||||
- No cron/worker jobs.
|
||||
- Server-side RBAC only; client checks are UX only.
|
||||
- Never log secrets, receipt bytes, or full invite codes (invite codes = **last4 only**).
|
||||
- Entries list endpoints must never return receipt bytes.
|
||||
|
||||
## Auth
|
||||
- Custom email/password auth.
|
||||
- Use HttpOnly session cookies backed by DB table `sessions`.
|
||||
- NEVER trust client-side RBAC checks.
|
||||
## Architecture quick map (follow existing patterns)
|
||||
- API routes: `app/api/**/route.ts` (thin parse/validate + call service)
|
||||
- Server services: `lib/server/*` (DB + authz, must include `import "server-only";`)
|
||||
- Client wrappers: `lib/client/*` (typed fetch + error normalization, credentials included)
|
||||
- Hooks: `hooks/use-*.ts` (UI-facing API layer; components avoid raw `fetch()`)
|
||||
|
||||
## Receipts
|
||||
- Store receipt images in Postgres `bytea` table `receipts`.
|
||||
- Entries list endpoints must not return image bytes.
|
||||
- Image bytes only fetched by separate endpoint when inspecting a single item.
|
||||
## API conventions
|
||||
- Prefer error shape: `{ error: { code, message }, request_id? }`
|
||||
- Validate input at the route boundary; authorize in services.
|
||||
|
||||
## 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.
|
||||
- Dodger Blue accent (#1E90FF).
|
||||
- Top navbar: left nav dropdown, middle group selector, right user menu.
|
||||
|
||||
## Code Rules
|
||||
- Small files, minimal comments.
|
||||
- Prefer single-line `if` without braces when only one line follows.
|
||||
- Heavy logic lives in components/hooks/services, not page files.
|
||||
- Prefer API calls via exported hooks/services for reusability and cleanliness (avoid raw fetch in components when possible).
|
||||
- Add/update unit tests with changes (TDD).
|
||||
- Heavy focus on code readability and maintainability; prioritize clean code over clever code.
|
||||
- ie. Separating different concerns into different files, and adding helper functions to avoid nested logic and improve readability, is preferred over clever one-liners or consolidating logic into fewer files.
|
||||
- ie. Separate groups of related codes by adding 3 line breaks between them
|
||||
|
||||
## Data Model
|
||||
- Users (system_role USER|SYS_ADMIN)
|
||||
- Groups + membership (group_role MEMBER|GROUP_ADMIN)
|
||||
- Entries (group-scoped) + optional receipt_id
|
||||
- User settings (jsonb)
|
||||
- Reports for system admins
|
||||
|
||||
## Architecture Contract (Backend ↔ Client ↔ Hooks ↔ UI)
|
||||
|
||||
### No-Assumptions Rule (Required)
|
||||
- Before making structural changes, first scan the repo and identify:
|
||||
- the web app root (where `app/`, `components/`, `hooks/`, `lib/` live)
|
||||
- existing API routes and helpers
|
||||
- existing patterns already in use
|
||||
- Do not invent files, endpoints, or conventions. If something is missing, add it minimally and consistently.
|
||||
|
||||
### Layering (Hard Boundaries)
|
||||
For every domain (auth, groups, entries, receipts, etc.), keep a consistent 4-layer flow:
|
||||
|
||||
1) **API Route Handlers** (`app/api/.../route.ts`)
|
||||
- Thin: parse input, call a server service, return JSON.
|
||||
- No direct DB queries inside route files unless there is no existing server service.
|
||||
- Must enforce auth & membership checks on server.
|
||||
|
||||
2) **Server Services (DB + authorization)** (`lib/server/*`)
|
||||
- Own all DB access and authorization helpers (sessions, requireGroupMember, etc.).
|
||||
- Server-only modules must include `import "server-only";`
|
||||
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/session.ts`.
|
||||
|
||||
3) **Client API Wrappers** (`lib/client/*`)
|
||||
- Typed fetch helpers only (no React state).
|
||||
- Centralize `fetchJson()` / error normalization.
|
||||
- Always send credentials (cookies) and never trust client-side RBAC.
|
||||
|
||||
4) **Hooks (UI-facing API layer)** (`hooks/use-*.ts`)
|
||||
- Hooks are the primary interface for components/pages to call APIs.
|
||||
- Components should not call `fetch()` directly unless 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.
|
||||
- Navbar layout: left nav dropdown, middle group selector, right user menu.
|
||||
|
||||
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)
|
||||
|
||||
## Core expectation
|
||||
This project connects to an external Postgres instance (on-prem server). Dev and Prod must share the same schema through migrations.
|
||||
## 1) Core expectation
|
||||
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
|
||||
|
||||
## Decisions / constraints (Group Settings)
|
||||
## 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.
|
||||
- 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.
|
||||
@ -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.
|
||||
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||
- API must generate `request_id` and return it in responses; audit logs must include it.
|
||||
- Audit logs must never store full invite codes (store last4 only).
|
||||
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||
|
||||
## Do first (vertical slice)
|
||||
---
|
||||
|
||||
## 7) Do first (vertical slice)
|
||||
1) DB migrate command + schema
|
||||
2) Register/Login/Logout (custom sessions)
|
||||
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
|
||||
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`
|
||||
- Tests + lint pass
|
||||
- RBAC enforced server-side
|
||||
- No large files
|
||||
- No TypeScript warnings or lint errors in touched files
|
||||
- No new cron/worker dependencies unless explicitly approved
|
||||
- No orphaned utilities/hooks/contexts after refactors
|
||||
- No duplicate mechanisms for the same state flow
|
||||
- Text encoding remains clean in user-facing strings/docs
|
||||
|
||||
## Desktop + mobile UX checklist (required)
|
||||
---
|
||||
|
||||
## 9) Desktop + mobile UX checklist (required)
|
||||
- Touch: long-press affordance for item-level actions when no visible button.
|
||||
- Mouse: hover affordance on interactive rows/cards.
|
||||
- Tap targets remain >= 40px on mobile.
|
||||
- Modal overlays must close on outside click/tap.
|
||||
- Use bubble notifications for main actions (create/update/delete/join).
|
||||
- Add Playwright UI tests for new UI features and critical flows.
|
||||
- Group role icons must be consistent: 👑 owner, 🛡️ admin, 👤 member.
|
||||
|
||||
## PR review checklist
|
||||
- Desktop + mobile UX checklist satisfied (hover + long-press where applicable).
|
||||
- No TypeScript warnings or lint errors introduced.
|
||||
---
|
||||
|
||||
## 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 { useParams, useRouter } from "next/navigation";
|
||||
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() {
|
||||
const params = useParams();
|
||||
@ -154,7 +154,7 @@ export default function InvitePage() {
|
||||
<div className="card-title">Invite details</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted">Loading invite…</div>
|
||||
<div className="text-sm text-muted">Loading invite...</div>
|
||||
) : error ? (
|
||||
<div className="text-sm text-red-400">{error}</div>
|
||||
) : link ? (
|
||||
@ -203,7 +203,7 @@ export default function InvitePage() {
|
||||
<div className="card-title">Join this group</div>
|
||||
</div>
|
||||
{checkingSession ? (
|
||||
<div className="text-sm text-muted">Checking session…</div>
|
||||
<div className="text-sm text-muted">Checking session...</div>
|
||||
) : !hasSession ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-muted">Sign in to accept this invite.</div>
|
||||
@ -244,7 +244,7 @@ export default function InvitePage() {
|
||||
disabled={!link || Boolean(result) || accepting}
|
||||
onClick={accept}
|
||||
>
|
||||
{accepting ? "Joining…" : actionLabel}
|
||||
{accepting ? "Joining..." : actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@ -280,3 +280,4 @@ export default function InvitePage() {
|
||||
</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";
|
||||
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import EntriesPanel from "@/components/entries-panel";
|
||||
import BucketsPanel from "@/components/buckets-panel";
|
||||
import EntriesPanel from "@/features/entries/components/entries-panel";
|
||||
import BucketsPanel from "@/features/buckets/components/buckets-panel";
|
||||
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 { useEffect, useRef } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
import DatePicker from "@/components/date-picker";
|
||||
|
||||
export type EntryDetailsForm = {
|
||||
amountDollars: string;
|
||||
@ -132,18 +134,18 @@ export default function EntryDetailsModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
className="flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
className="flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
disabled={!canNavigate}
|
||||
aria-label="Previous entry"
|
||||
>
|
||||
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
|
||||
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
|
||||
</button>
|
||||
<h2 className="text-center text-lg font-semibold">Entry details</h2>
|
||||
<h2 className="text-center text-lg font-semibold">Entry Details</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="ml-auto flex w-24 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
className="ml-auto flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
|
||||
disabled={!canNavigate}
|
||||
aria-label="Next entry"
|
||||
>
|
||||
@ -163,7 +165,19 @@ export default function EntryDetailsModal({
|
||||
}}
|
||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||
>
|
||||
|
||||
|
||||
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
||||
<ToggleButtonGroup
|
||||
value={form.entryType}
|
||||
onChange={entryType => onChange({ entryType })}
|
||||
ariaLabel="Entry type"
|
||||
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "SPENDING", label: "Spending" },
|
||||
{ value: "INCOME", label: "Income" }
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
|
||||
@ -171,25 +185,14 @@ export default function EntryDetailsModal({
|
||||
title="Toggle Recurring Entry"
|
||||
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
|
||||
>
|
||||
<span aria-hidden>⟳</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||
>
|
||||
Spending
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||
>
|
||||
Income
|
||||
<span aria-hidden className="font-bold text-[25px]"
|
||||
style={{ transform: `translateY(-2px)` }}
|
||||
>∞</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<label className="text-sm text-muted">
|
||||
Amount ($)
|
||||
<input
|
||||
@ -204,32 +207,27 @@ export default function EntryDetailsModal({
|
||||
/>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<input
|
||||
<DatePicker
|
||||
name="occurredAt"
|
||||
type="date"
|
||||
className={`no-date-icon mt-6 w-full input-base px-3 py-2 text-sm ${dateChanged ? changedInputClass : ""} ${form.occurredAt ? "" : "border-red-400/70"}`}
|
||||
value={form.occurredAt}
|
||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||
onChange={occurredAt => onChange({ occurredAt })}
|
||||
required
|
||||
className={`mt-1 ${dateChanged ? changedInputClass : ""}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
<div className={`mt-6 flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`} role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
value={form.necessity}
|
||||
onChange={necessity => onChange({ necessity })}
|
||||
ariaLabel="Necessity"
|
||||
className={`flex items-center gap-2 rounded-full border border-accent-weak bg-panel ${necessityChanged ? changedInputClass : ""}`}
|
||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TagInput
|
||||
label="Tags"
|
||||
@ -269,22 +267,18 @@ export default function EntryDetailsModal({
|
||||
<option value="QUARTERLY">quarter(s)</option>
|
||||
<option value="YEARLY">year(s)</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||
{([
|
||||
<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: "BY_DATE", label: "Until" },
|
||||
{ value: "AFTER_COUNT", label: "After" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ endCondition: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
{form.endCondition === "AFTER_COUNT" ? (
|
||||
<input
|
||||
type="number"
|
||||
@ -296,26 +290,12 @@ export default function EntryDetailsModal({
|
||||
/>
|
||||
) : null}
|
||||
{form.endCondition === "BY_DATE" ? (
|
||||
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() - 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>‹</button>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||
<DatePicker
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
@ -332,7 +312,7 @@ export default function EntryDetailsModal({
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="button"
|
||||
onClick={onRevert}
|
||||
@ -369,10 +349,11 @@ export default function EntryDetailsModal({
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<div className="flex-1 w-full" />
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Close
|
||||
|
||||
@ -4,6 +4,7 @@ import type React from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
|
||||
type BucketForm = {
|
||||
name: string;
|
||||
@ -125,22 +126,18 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
||||
/>
|
||||
</label>
|
||||
<div className="text-sm text-muted md:col-span-2">
|
||||
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
value={form.necessity}
|
||||
onChange={necessity => onChange({ necessity })}
|
||||
ariaLabel="Necessity"
|
||||
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Description
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
||||
import DatePicker from "@/components/date-picker";
|
||||
|
||||
type NewEntryForm = {
|
||||
amountDollars: string;
|
||||
@ -54,17 +56,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]);
|
||||
|
||||
|
||||
|
||||
function shiftDate(days: number) {
|
||||
const base = form.occurredAt ? new Date(form.occurredAt) : new Date();
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() + days);
|
||||
onChange({ occurredAt: base.toISOString().slice(0, 10) });
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@ -88,23 +79,17 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full mr-[-10px] px-4 py-2.5 text-xs font-semibold ${form.entryType === "SPENDING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "SPENDING" ? "INCOME" : "SPENDING" })}
|
||||
>
|
||||
Spending
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-4 py-2.5 text-xs font-semibold ${form.entryType === "INCOME" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ entryType: form.entryType === "INCOME" ? "SPENDING" : "INCOME" })}
|
||||
>
|
||||
Income
|
||||
</button>
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
value={form.entryType}
|
||||
onChange={entryType => onChange({ entryType })}
|
||||
ariaLabel="Entry type"
|
||||
className="flex items-center gap-[-50px] rounded-full border border-accent-weak bg-panel"
|
||||
sizeClassName="px-4 py-2.5 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "SPENDING", label: "Spending", className: "mr-[-10px]" },
|
||||
{ value: "INCOME", label: "Income" }
|
||||
]}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@ -113,7 +98,9 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
title="Toggle Recurring 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>
|
||||
</div>
|
||||
<form
|
||||
@ -148,38 +135,27 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
</div>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<div className={`mt-1 inline-flex w-full items-center overflow-hidden rounded-full border ${form.occurredAt ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-7)}>«</button>
|
||||
<button type="button" className="h-11 w-full text-sm text-muted hover:bg-accent-soft" onClick={() => shiftDate(-1)}>‹</button>
|
||||
<input
|
||||
<DatePicker
|
||||
name="occurredAt"
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-sm text-[color:var(--color-text)] outline-none"
|
||||
value={form.occurredAt}
|
||||
onChange={e => onChange({ occurredAt: e.target.value })}
|
||||
onChange={occurredAt => onChange({ occurredAt })}
|
||||
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 className="text-sm text-muted">
|
||||
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`flex-1 rounded-full px-3 py-2.5 text-xs font-semibold ${form.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ necessity: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
value={form.necessity}
|
||||
onChange={necessity => onChange({ necessity })}
|
||||
ariaLabel="Necessity"
|
||||
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
|
||||
sizeClassName="px-3 py-2.5 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
|
||||
{ value: "BOTH", label: "Both", className: "flex-1" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TAGS */}
|
||||
@ -200,17 +176,17 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
{/* RECURRING OPTIONS */}
|
||||
{form.isRecurring ? (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-sm text-muted">Frequency Conditions</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
|
||||
<div className="text-sm text-muted mr-2">Every</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="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}
|
||||
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
|
||||
/>
|
||||
<select
|
||||
className="w-20 min-w-[110px] input-base px-3 py-2 text-center text-sm"
|
||||
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
|
||||
value={form.frequency}
|
||||
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
|
||||
>
|
||||
@ -219,22 +195,18 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
<option value="MONTHLY">month(s)</option>
|
||||
<option value="YEARLY">year(s)</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel" role="group" aria-label="End condition">
|
||||
{([
|
||||
<ToggleButtonGroup
|
||||
value={form.endCondition}
|
||||
onChange={endCondition => onChange({ endCondition })}
|
||||
ariaLabel="End condition"
|
||||
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
|
||||
sizeClassName="px-3 py-3 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "NEVER", label: "Forever" },
|
||||
{ value: "BY_DATE", label: "Until" },
|
||||
{ value: "AFTER_COUNT", label: "After" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-3 text-xs font-semibold ${form.endCondition === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => onChange({ endCondition: option.value })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
{form.endCondition === "AFTER_COUNT" ? (
|
||||
<input
|
||||
type="number"
|
||||
@ -246,26 +218,12 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
/>
|
||||
) : null}
|
||||
{form.endCondition === "BY_DATE" ? (
|
||||
<div className={`inline-flex items-center overflow-hidden rounded-full border ${form.endDate ? "border-accent-weak" : "border-red-400/70"} bg-panel`}>
|
||||
<button type="button" className="h-11 w-20 text-sm text-muted hover:bg-accent-soft" onClick={() => {
|
||||
const base = form.endDate ? new Date(form.endDate) : new Date(form.occurredAt || Date.now());
|
||||
if (Number.isNaN(base.getTime())) return;
|
||||
base.setDate(base.getDate() - 1);
|
||||
onChange({ endDate: base.toISOString().slice(0, 10) });
|
||||
}}>‹</button>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon h-11 w-40 bg-transparent px-3 text-center text-sm text-[color:var(--color-text)] outline-none"
|
||||
<DatePicker
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
@ -286,7 +244,7 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
|
||||
type="submit"
|
||||
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>
|
||||
{error ? <div className="text-sm text-red-400">{error}</div> : null}
|
||||
</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 expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
await dropdown.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Alpha Household GROUP_ADMIN" })).toBeVisible();
|
||||
@ -18,7 +18,7 @@ test("group settings show join requests and policy", async ({ page }) => {
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.getByRole("button", { name: "Group settings" }).click();
|
||||
await expect(page).toHaveURL(/\/groups\/[0-9]+\/settings/);
|
||||
await expect(page).toHaveURL(/\/groups\/settings/);
|
||||
|
||||
await expect(page.getByText("Join requests")).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.getByRole("heading", { name: "Entries" })).toBeVisible();
|
||||
await expect(page.getByText("$12.50")).toBeVisible();
|
||||
await expect(page.getByText("#Food")).toBeVisible();
|
||||
await expect(page.getByText("#Travel")).toBeVisible();
|
||||
await expect(page.getByText("No tags")).toBeVisible();
|
||||
await expect(page.getByText("$12.50").first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "#Food" }).first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();
|
||||
await expect(page.locator("span:visible", { hasText: "No tags" }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("entry details modal opens", async ({ page }) => {
|
||||
await login(page, "owner1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.getByText("$12.50").click();
|
||||
await page.getByText("$12.50").first().click();
|
||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Entry details" })).toBeHidden();
|
||||
@ -26,13 +26,14 @@ test("empty tag callout shows contact admin for members", async ({ page }) => {
|
||||
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
await dropdown.click();
|
||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||
await waitSetActive;
|
||||
|
||||
await page.getByRole("button", { name: "Add entry" }).click();
|
||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||
const callout = page.getByRole("button", { name: "No Tags Assigned Yet - Contact Your Group Admin" });
|
||||
await expect(callout).toBeVisible();
|
||||
await expect(callout).toBeDisabled();
|
||||
@ -42,13 +43,14 @@ test("empty tag callout navigates to settings for admins", async ({ page }) => {
|
||||
await login(page, "member1@fiddy.dev", "FiddyDev123!");
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
const dropdown = page.getByRole("button", { name: /Group:/ });
|
||||
const dropdown = page.getByRole("button", { name: /▼$/ });
|
||||
await dropdown.click();
|
||||
const waitSetActive = page.waitForResponse(res => res.url().includes("/api/groups/active") && res.request().method() === "POST");
|
||||
await page.getByRole("button", { name: /Gamma Club/ }).click();
|
||||
await waitSetActive;
|
||||
|
||||
await page.getByRole("button", { name: "Add entry" }).click();
|
||||
await page.getByPlaceholder("Add tags... (who, where, why, etc.)").fill("missing");
|
||||
await page.getByRole("button", { name: "No Tags Assigned Yet - Click To Assign Tags" }).click();
|
||||
await expect(page).toHaveURL(/\/groups\/[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 type { Bucket } from "@/lib/client/buckets";
|
||||
|
||||
type BucketCardProps = {
|
||||
bucket: Bucket;
|
||||
|
||||
icon?: string | null;
|
||||
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: (bucketId: number) => void;
|
||||
|
||||
isMenuOpen: boolean;
|
||||
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
|
||||
setConfirmDeleteId: (bucketId: number) => void;
|
||||
openEdit: (bucketId: number) => void;
|
||||
|
||||
limit: number;
|
||||
|
||||
usageLabel: string;
|
||||
renderUsageBar: (bucket: Bucket) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function BucketCard({
|
||||
@ -33,8 +25,13 @@ export function BucketCard({
|
||||
openEdit,
|
||||
limit,
|
||||
usageLabel,
|
||||
renderUsageBar,
|
||||
}: BucketCardProps) {
|
||||
const spent = bucket.totalUsage || 0;
|
||||
const rawPercent = limit > 0 ? (spent / limit) * 100 : 0;
|
||||
const progressPercent = Math.max(0, Math.min(100, rawPercent));
|
||||
const progressColor = rawPercent > 100 ? "#ef4444" : rawPercent >= 80 ? "#facc15" : "#4ade80";
|
||||
const ringTrackColor = "rgba(148, 163, 184, 0.25)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full 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 min-w-0 items-start gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||
{icon || "🚫"}
|
||||
{limit > 0 ? (
|
||||
<div className="relative h-11 w-11 shrink-0">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(${progressColor} 0% ${progressPercent}%, ${ringTrackColor} ${progressPercent}% 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-[5px] flex items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||
{icon || "?"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
||||
{icon || "?"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{bucket.name}</div>
|
||||
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
||||
{bucket.description ? (
|
||||
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
||||
{bucket.description}
|
||||
@ -67,7 +78,7 @@ export function BucketCard({
|
||||
aria-label="Bucket actions"
|
||||
data-bucket-menu-button
|
||||
>
|
||||
⋯
|
||||
...
|
||||
</button>
|
||||
|
||||
{isMenuOpen ? (
|
||||
@ -100,7 +111,6 @@ export function BucketCard({
|
||||
|
||||
{limit > 0 ? (
|
||||
<>
|
||||
{renderUsageBar(bucket)}
|
||||
{isExpanded ? (
|
||||
<div className="mt-2 space-y-2 text-xs text-soft">
|
||||
<div>{usageLabel}</div>
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useGroupsContext } from "@/hooks/groups-context";
|
||||
import useBuckets from "@/hooks/use-buckets";
|
||||
import useTags from "@/hooks/use-tags";
|
||||
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
||||
import useTags from "@/features/tags/hooks/use-tags";
|
||||
import NewBucketModal from "@/components/new-bucket-modal";
|
||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||
@ -121,27 +121,7 @@ export default function BucketsPanel() {
|
||||
function budgetUsage(bucket: typeof buckets[number]) {
|
||||
const limit = bucket.budgetLimitDollars || 0;
|
||||
const spent = bucket.totalUsage || 0;
|
||||
const pct = limit > 0 ? (spent / limit) * 100 : 0;
|
||||
return { limit, spent, pct };
|
||||
}
|
||||
|
||||
function renderUsageBar(bucket: typeof buckets[number]) {
|
||||
const { limit, spent, pct } = budgetUsage(bucket);
|
||||
if (!limit) return null;
|
||||
const clamped = Math.max(0, pct);
|
||||
const overage = Math.max(0, clamped - 100);
|
||||
const overColor = clamped > 200 ? "bg-red-500" : "bg-yellow-400";
|
||||
const tone = overage > 0 ? overColor : clamped >= 80 ? "bg-yellow-400" : "bg-green-400";
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 w-full rounded-full bg-surface">
|
||||
<div
|
||||
className={`h-2 rounded-full ${tone}`}
|
||||
style={{ width: `${Math.min(100, clamped)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return { limit, spent };
|
||||
}
|
||||
|
||||
return (
|
||||
@ -195,7 +175,6 @@ export default function BucketsPanel() {
|
||||
openEdit={openEdit}
|
||||
limit={limit}
|
||||
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 { 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 NewEntryModal from "@/components/new-entry-modal";
|
||||
import EntryDetailsModal from "@/components/entry-details-modal";
|
||||
import { useNotificationsContext } from "@/hooks/notifications-context";
|
||||
import useTags from "@/hooks/use-tags";
|
||||
import useTags from "@/features/tags/hooks/use-tags";
|
||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
||||
import useGroupSettings from "@/hooks/use-group-settings";
|
||||
import TagInput from "@/components/tag-input";
|
||||
import { emitEntryMutated } from "@/lib/client/entry-mutation-events";
|
||||
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||
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() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const { groups, activeGroupId } = useGroupsContext();
|
||||
const router = useRouter();
|
||||
const { entries, loading, error, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||
const { notifyEntryMutation } = useEntryMutation();
|
||||
const { notify } = useNotificationsContext();
|
||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||
const { settings } = useGroupSettings(activeGroupId);
|
||||
@ -64,7 +68,7 @@ export default function EntriesPanel() {
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [entryTab, setEntryTab] = useState<"SINGLE" | "RECURRING">("SINGLE");
|
||||
const emptyFilters = {
|
||||
const emptyFilters: EntriesFilters = {
|
||||
amountMin: "",
|
||||
amountMax: "",
|
||||
dateFrom: "",
|
||||
@ -210,10 +214,10 @@ export default function EntriesPanel() {
|
||||
setIsModalOpen(false);
|
||||
notify({
|
||||
title: "Entry added",
|
||||
message: `${form.tags.join(", ")} · $${amountDollars.toFixed(2)}`,
|
||||
message: `${form.tags.join(", ")} - $${amountDollars.toFixed(2)}`,
|
||||
tone: "success"
|
||||
});
|
||||
emitEntryMutated({ before: null, after: createdEntry });
|
||||
notifyEntryMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@ -364,9 +368,9 @@ export default function EntriesPanel() {
|
||||
setRemovedTags([]);
|
||||
notify({
|
||||
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",
|
||||
tone: "danger"
|
||||
});
|
||||
emitEntryMutated({ before: deletedEntry || beforeEntry, after: null });
|
||||
notifyEntryMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@ -435,22 +439,16 @@ export default function EntriesPanel() {
|
||||
<div className="card-header">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="card-title text-lg">Entries</h2>
|
||||
<div className="flex items-center gap-0 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`mr-[-10px] w-20 rounded-full px-3 py-2 text-xs font-semibold ${entryTab === "SINGLE" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setEntryTab(prev => prev === "SINGLE" ? "RECURRING" : "SINGLE")}
|
||||
>
|
||||
Existing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full w-20 px-3 py-2 text-xs font-semibold ${entryTab === "RECURRING" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setEntryTab(prev => prev === "RECURRING" ? "SINGLE" : "RECURRING")}
|
||||
>
|
||||
Scheduled
|
||||
</button>
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
value={entryTab}
|
||||
onChange={setEntryTab}
|
||||
ariaLabel="Entries tab"
|
||||
sizeClassName="px-3 py-2 text-xs font-semibold"
|
||||
options={[
|
||||
{ value: "SINGLE", label: "Existing", className: "mr-[-10px] w-20" },
|
||||
{ value: "RECURRING", label: "Scheduled", className: "w-20" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@ -472,87 +470,15 @@ export default function EntriesPanel() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{!activeGroupId ? (
|
||||
<div className="text-sm text-muted">Select a group to view entries.</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map(row => (
|
||||
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-surface" />
|
||||
<div className="h-3 w-40 rounded bg-surface" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="h-5 w-14 rounded-full bg-surface" />
|
||||
<div className="h-5 w-12 rounded-full bg-surface" />
|
||||
<div className="h-5 w-16 rounded-full bg-surface" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length ? (
|
||||
visibleEntries.length ? (
|
||||
visibleEntries.map((entry, index) => {
|
||||
const tags = entry.tags ?? [];
|
||||
const mobileTagLimit = 2;
|
||||
const mobileTags = tags.slice(0, mobileTagLimit);
|
||||
const extraTagCount = Math.max(tags.length - mobileTagLimit, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
|
||||
onClick={() => handleOpenDetails(entry, index)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity}
|
||||
</div>
|
||||
</div>
|
||||
{tags.length ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-end gap-2 md:hidden">
|
||||
{mobileTags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTagCount ? (
|
||||
<span className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft">
|
||||
{extraTagCount} more...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden flex-wrap justify-end gap-2 md:flex">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="space-y-2 text-sm text-muted">
|
||||
<div>No matching entries.</div>
|
||||
{activeFilterCount ? (
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={handleClearFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted">No entries yet.</div>
|
||||
)}
|
||||
</div>
|
||||
<EntriesList
|
||||
activeGroupId={activeGroupId}
|
||||
loading={loading}
|
||||
entries={entries}
|
||||
visibleEntries={visibleEntries}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NewEntryModal
|
||||
@ -593,182 +519,25 @@ export default function EntriesPanel() {
|
||||
loopHintNext={selectedIndex === totalEntries - 1 && totalEntries > 1 ? "Loop" : ""}
|
||||
canNavigate={totalEntries > 1}
|
||||
/>
|
||||
{filterOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setFilterOpen(false)}>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") setFilterOpen(false);
|
||||
}}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Filter Entries</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(false)}
|
||||
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Amount Range
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.amountMin}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, amountMin: e.target.value }))}
|
||||
<EntriesFilterModal
|
||||
isOpen={filterOpen}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
tagSuggestions={tagSuggestions}
|
||||
canManageTags={canManageTags}
|
||||
emptyTagActionLabel={emptyTagActionLabel}
|
||||
onEmptyTagAction={handleEmptyTagAction}
|
||||
onClearFilters={handleClearFilters}
|
||||
onFilterAddTag={handleFilterAddTag}
|
||||
onFilterToggleTag={handleFilterToggleTag}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
<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 }))}
|
||||
<EntriesDiscardModal
|
||||
isOpen={discardOpen}
|
||||
onCancel={handleCancelDiscard}
|
||||
onConfirm={handleConfirmDiscard}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="text-sm text-muted md:col-span-2">
|
||||
Date Range
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.dateFrom}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, dateFrom: e.target.value }))}
|
||||
/>
|
||||
<span className="text-xs text-soft">-</span>
|
||||
<input
|
||||
type="date"
|
||||
className="no-date-icon w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.dateTo}
|
||||
placeholder="none"
|
||||
onChange={e => setFilters(prev => ({ ...prev, dateTo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="text-sm text-muted">
|
||||
<div className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel" role="group" aria-label="Necessity">
|
||||
{([
|
||||
{ value: "ANY", label: "Any" },
|
||||
{ value: "NECESSARY", label: "Necessary" },
|
||||
{ value: "BOTH", label: "Both" },
|
||||
{ value: "UNNECESSARY", label: "Unnecessary" }
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.necessity === option.value ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, necessity: prev.necessity === option.value ? "ANY" : option.value }))}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="text-sm text-muted">
|
||||
Notes contains
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full input-base px-3 py-2 text-sm"
|
||||
value={filters.notesQuery}
|
||||
onChange={e => setFilters(prev => ({ ...prev, notesQuery: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
<TagInput
|
||||
label="Tags"
|
||||
labelAction={
|
||||
<div className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ANY" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ANY" ? "ALL" : "ANY" }))}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-xs font-semibold ${filters.tagsMode === "ALL" ? "btn-accent" : "text-muted"}`}
|
||||
onClick={() => setFilters(prev => ({ ...prev, tagsMode: prev.tagsMode === "ALL" ? "ANY" : "ALL" }))}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
tags={filters.tags}
|
||||
suggestions={tagSuggestions}
|
||||
allowCustom={false}
|
||||
onToggleTag={handleFilterToggleTag}
|
||||
onAddTag={handleFilterAddTag}
|
||||
emptySuggestionLabel={emptyTagActionLabel}
|
||||
emptySuggestionDisabled={!canManageTags}
|
||||
onEmptySuggestionClick={handleEmptyTagAction}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-xs text-soft">
|
||||
{activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="rounded-lg btn-outline-accent px-3 py-2 text-xs" onClick={handleClearFilters}>
|
||||
Clear Filters
|
||||
</button>
|
||||
<button type="button" className="rounded-lg btn-accent px-3 py-2 text-xs" onClick={() => setFilterOpen(false)}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{discardOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5 shadow-xl"
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") handleCancelDiscard();
|
||||
}}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="text-lg font-semibold">Discard changes?</div>
|
||||
<p className="mt-2 text-sm text-muted">You have unsaved changes. Do you want to discard them?</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
|
||||
onClick={handleCancelDiscard}
|
||||
>
|
||||
Keep editing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
|
||||
onClick={handleConfirmDiscard}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ConfirmSlideModal
|
||||
isOpen={confirmDeleteOpen}
|
||||
title="Delete entry"
|
||||
@ -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";
|
||||
|
||||
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);
|
||||
|
||||
@ -19,3 +19,4 @@ export function useAuthContext() {
|
||||
if (!ctx) throw new Error("AuthProvider is missing");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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);
|
||||
|
||||
@ -19,3 +19,4 @@ export function useGroupsContext() {
|
||||
if (!ctx) throw new Error("GroupsProvider is missing");
|
||||
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;
|
||||
}
|
||||
|
||||
const baseContext = payload.context || {};
|
||||
const logContext = { ...baseContext, route, requestId, status };
|
||||
logApiError({ code: payload.code, message: payload.message, context: logContext });
|
||||
|
||||
if (debugEnabled) {
|
||||
const context = payload.context || {};
|
||||
payload = { ...payload, context: { ...context, route } };
|
||||
logApiError(payload);
|
||||
payload = { ...payload, context: redactContext(logContext) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
if (process.env.NODE_ENV !== "test")
|
||||
require("server-only");
|
||||
import crypto from "node:crypto";
|
||||
import getPool from "@/lib/server/db";
|
||||
import { apiError } from "@/lib/server/errors";
|
||||
|
||||
@ -11,6 +12,7 @@ type LimitInput = {
|
||||
};
|
||||
|
||||
let ensureTablePromise: Promise<void> | null = null;
|
||||
let lastCleanupAtMs = 0;
|
||||
|
||||
async function ensureRateLimitsTable() {
|
||||
if (!ensureTablePromise) {
|
||||
@ -30,6 +32,22 @@ async function ensureRateLimitsTable() {
|
||||
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) {
|
||||
const bucketStart = Math.floor(nowMs / windowMs) * windowMs;
|
||||
return new Date(bucketStart);
|
||||
@ -37,6 +55,7 @@ function normalizeWindowStart(nowMs: number, windowMs: number) {
|
||||
|
||||
async function consumeRateLimit(input: LimitInput) {
|
||||
await ensureRateLimitsTable();
|
||||
await cleanupStaleRateLimits();
|
||||
const now = Date.now();
|
||||
const windowStart = normalizeWindowStart(now, input.windowMs);
|
||||
const pool = getPool();
|
||||
@ -74,8 +93,8 @@ export async function enforceAuthRateLimit(input: {
|
||||
identifierLimit?: number;
|
||||
windowMs?: number;
|
||||
}) {
|
||||
const scope = `auth:${input.route}`;
|
||||
const ip = String(input.ip || "unknown").trim().toLowerCase();
|
||||
const scope = normalizeSegment(`auth:${input.route}`);
|
||||
const ip = normalizeSegment(String(input.ip || "unknown"));
|
||||
const windowMs = input.windowMs ?? (15 * 60 * 1000);
|
||||
await consumeRateLimit({
|
||||
key: `${scope}:ip:${ip}`,
|
||||
@ -84,7 +103,7 @@ export async function enforceAuthRateLimit(input: {
|
||||
windowMs
|
||||
});
|
||||
|
||||
const identifier = String(input.identifier || "").trim().toLowerCase();
|
||||
const identifier = normalizeSegment(String(input.identifier || ""), false);
|
||||
if (identifier) {
|
||||
await consumeRateLimit({
|
||||
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 }) {
|
||||
const scope = normalizeSegment(input.scope);
|
||||
await consumeRateLimit({
|
||||
key: `write:user:${input.userId}:scope:${input.scope}`,
|
||||
scope: input.scope,
|
||||
key: `write:user:${input.userId}:scope:${scope}`,
|
||||
scope,
|
||||
limit: input.limit ?? 120,
|
||||
windowMs: input.windowMs ?? (15 * 60 * 1000)
|
||||
});
|
||||
|
||||
@ -3,11 +3,21 @@ if (process.env.NODE_ENV !== "test")
|
||||
import { headers } from "next/headers";
|
||||
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() {
|
||||
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 {
|
||||
requestId: createRequestId(),
|
||||
ip: headerStore.get("x-forwarded-for") || headerStore.get("x-real-ip"),
|
||||
requestId,
|
||||
ip,
|
||||
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/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// 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";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./features/**/*.{ts,tsx}",
|
||||
"./shared/**/*.{ts,tsx}"
|
||||
],
|
||||
theme: { extend: {} },
|
||||
plugins: []
|
||||
} satisfies Config;
|
||||
|
||||
@ -35,6 +35,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
|
||||
location /api/auth/login {
|
||||
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-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_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 run build`: pass.
|
||||
- `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
|
||||
- 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/lib/server/entries.ts`
|
||||
- `apps/web/lib/client/entries.ts`
|
||||
- `apps/web/hooks/use-entries.ts`
|
||||
- `apps/web/components/entries-panel.tsx`
|
||||
- `apps/web/features/entries/hooks/use-entries.ts`
|
||||
- `apps/web/features/entries/components/entries-panel.tsx`
|
||||
- `apps/web/app/page.tsx`
|
||||
- `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.
|
||||
|
||||
## Files
|
||||
- `apps/web/hooks/use-entries.ts`
|
||||
- `apps/web/hooks/use-auth.ts`
|
||||
- `apps/web/hooks/use-groups.ts`
|
||||
- `apps/web/components/entries-panel.tsx`
|
||||
- `apps/web/features/entries/hooks/use-entries.ts`
|
||||
- `apps/web/features/auth/hooks/use-auth.ts`
|
||||
- `apps/web/features/groups/hooks/use-groups.ts`
|
||||
- `apps/web/features/entries/components/entries-panel.tsx`
|
||||
- `apps/web/components/navbar.tsx`
|
||||
- `apps/web/app/login/page.tsx`
|
||||
- `apps/web/app/register/page.tsx`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user