diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..cfde001 --- /dev/null +++ b/.codex/config.toml @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0efb000..7bee2ed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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//...` -- `lib/server/.ts` -- `lib/client/.ts` -- `hooks/use-.ts` -- `components//*` -- `__tests__/.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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..56ce058 --- /dev/null +++ b/AGENTS.md @@ -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). diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index 1ec9d2c..f928afa 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -1,24 +1,129 @@ # 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//...` + `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. - Invite links: - - TTL limited to 1–7 days. - - Settings are immutable after creation (policy, single-use, etc.). - - Single-use does not override approval-required. - - Expired links are retained and can be revived. - - Single-use links are deleted after successful use. - - Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event. + - TTL limited to 1–7 days. + - Settings are immutable after creation (policy, single-use, etc.). + - Single-use does not override approval-required. + - Expired links are retained and can be revived. + - Single-use links are deleted after successful use. + - Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event. - No cron/worker jobs for now (auto ownership transfer and invite rotation are paused). -- API must generate `request_id` and return it in responses; audit logs must include it. -- Audit logs must never store full invite codes (store last4 only). +- 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 diff --git a/apps/web/app/invite/[token]/page.tsx b/apps/web/app/invite/[token]/page.tsx index 62ff1c1..f70e301 100644 --- a/apps/web/app/invite/[token]/page.tsx +++ b/apps/web/app/invite/[token]/page.tsx @@ -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() {
Invite details
{loading ? ( -
Loading invite…
+
Loading invite...
) : error ? (
{error}
) : link ? ( @@ -203,7 +203,7 @@ export default function InvitePage() {
Join this group
{checkingSession ? ( -
Checking session…
+
Checking session...
) : !hasSession ? (
Sign in to accept this invite.
@@ -244,7 +244,7 @@ export default function InvitePage() { disabled={!link || Boolean(result) || accepting} onClick={accept} > - {accepting ? "Joining…" : actionLabel} + {accepting ? "Joining..." : actionLabel} ) : null} + +
+ + + ); +} diff --git a/apps/web/components/dashboard-content.tsx b/apps/web/components/dashboard-content.tsx index 4b3264d..8c1b301 100644 --- a/apps/web/components/dashboard-content.tsx +++ b/apps/web/components/dashboard-content.tsx @@ -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"; diff --git a/apps/web/components/date-picker.tsx b/apps/web/components/date-picker.tsx new file mode 100644 index 0000000..3ec3b3d --- /dev/null +++ b/apps/web/components/date-picker.tsx @@ -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 ( +
+ {showWeekButtons ? ( + + ) : null} + + onChange(e.target.value)} + required={required} + /> + + {showWeekButtons ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/components/entry-details-modal.tsx b/apps/web/components/entry-details-modal.tsx index feb3a36..1ed425e 100644 --- a/apps/web/components/entry-details-modal.tsx +++ b/apps/web/components/entry-details-modal.tsx @@ -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({ -

Entry details

+

Entry Details

-
- - -
+ + +
- onChange({ occurredAt: e.target.value })} + onChange={occurredAt => onChange({ occurredAt })} required + className={`mt-1 ${dateChanged ? changedInputClass : ""}`} />
-
- {([ - { value: "NECESSARY", label: "Necessary" }, - { value: "BOTH", label: "Both" }, - { value: "UNNECESSARY", label: "Unnecessary" } - ] as const).map(option => ( - - ))} -
+ 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" } + ]} + />
quarter(s) -
- {([ + 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 => ( - - ))} -
+ ]} + /> {form.endCondition === "AFTER_COUNT" ? ( ) : null} {form.endCondition === "BY_DATE" ? ( -
- - onChange({ endDate: e.target.value })} - /> - -
+ onChange({ endDate })} + showWeekButtons={false} + centerInput + /> ) : null} @@ -332,7 +312,7 @@ export default function EntryDetailsModal({ />
-
+
- ))} -
+ 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" } + ]} + />
-
- - -
+ 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" } + ]} + />
-
- - - onChange({ occurredAt: e.target.value })} - required - /> - - -
+ onChange({ occurredAt })} + required + className="mt-1" + />
-
- {([ - { value: "NECESSARY", label: "Necessary" }, - { value: "BOTH", label: "Both" }, - { value: "UNNECESSARY", label: "Unnecessary" } - ] as const).map(option => ( - - ))} -
+ 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" } + ]} + />
{/* TAGS */} @@ -200,17 +176,17 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, {/* RECURRING OPTIONS */} {form.isRecurring ? (
-
Frequency Conditions
-
+
+
Every
onChange({ intervalCount: Number(e.target.value || 1) })} /> -
- {([ + 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 => ( - - ))} -
+ ]} + /> {form.endCondition === "AFTER_COUNT" ? ( ) : null} {form.endCondition === "BY_DATE" ? ( -
- - onChange({ endDate: e.target.value })} - /> - -
+ onChange({ endDate })} + showWeekButtons={false} + centerInput + /> ) : null}
@@ -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"} {error ?
{error}
: null}
diff --git a/apps/web/components/recurring-entries-panel.tsx b/apps/web/components/recurring-entries-panel.tsx deleted file mode 100644 index 4675eb2..0000000 --- a/apps/web/components/recurring-entries-panel.tsx +++ /dev/null @@ -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 ( -
-
-

Recurring entries

-
-
- {!activeGroupId ? ( -
Select a group to view recurring entries.
- ) : loading ? ( -
- {[0, 1].map(row => ( -
-
-
-
-
-
- ))} -
- ) : recurring.length ? ( - recurring.map(entry => { - const monthly = entry.frequency ? monthlyMultiplier(entry.frequency, entry.intervalCount) * entry.amountDollars : 0; - return ( -
-
-
${entry.amountDollars.toFixed(2)} · {entry.tags.join(", ") || "No tags"}
-
{entry.entryType}
-
-
- Next run: {entry.nextRunAt || entry.occurredAt} · Monthly est: ${monthly.toFixed(2)} -
-
- ); - }) - ) : ( -
No recurring entries yet.
- )} -
-
- ); -} diff --git a/apps/web/components/toggle-button-group.tsx b/apps/web/components/toggle-button-group.tsx new file mode 100644 index 0000000..05ca163 --- /dev/null +++ b/apps/web/components/toggle-button-group.tsx @@ -0,0 +1,77 @@ +"use client"; + +type ToggleButtonOption = { + value: T; + label: string; + className?: string; + activeClassName?: string; + inactiveClassName?: string; + disabled?: boolean; + ariaLabel?: string; + onClick?: () => void; +}; + +type ToggleButtonGroupProps = { + value?: T | null; + options: ToggleButtonOption[]; + 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) { + return parts.filter(Boolean).join(" "); +} + +export default function ToggleButtonGroup({ + 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) { + return ( +
+ {options.map(option => { + const isActive = value != null && option.value === value; + const onClick = option.onClick + ? option.onClick + : onChange + ? () => onChange(option.value) + : undefined; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/e2e/groups.spec.ts b/apps/web/e2e/groups.spec.ts index cae4a52..a4ad984 100644 --- a/apps/web/e2e/groups.spec.ts +++ b/apps/web/e2e/groups.spec.ts @@ -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(); diff --git a/apps/web/e2e/spendings.spec.ts b/apps/web/e2e/spendings.spec.ts index bb2a44d..1308508 100644 --- a/apps/web/e2e/spendings.spec.ts +++ b/apps/web/e2e/spendings.spec.ts @@ -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/); }); diff --git a/apps/web/features/README.md b/apps/web/features/README.md new file mode 100644 index 0000000..e3b6959 --- /dev/null +++ b/apps/web/features/README.md @@ -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. diff --git a/apps/web/features/auth/README.md b/apps/web/features/auth/README.md new file mode 100644 index 0000000..a671d87 --- /dev/null +++ b/apps/web/features/auth/README.md @@ -0,0 +1,3 @@ +# Auth Feature + +Reserved for auth domain modules (components/hooks/lib) during incremental migration. diff --git a/apps/web/hooks/use-auth.ts b/apps/web/features/auth/hooks/use-auth.ts similarity index 100% rename from apps/web/hooks/use-auth.ts rename to apps/web/features/auth/hooks/use-auth.ts diff --git a/apps/web/components/bucket-card.tsx b/apps/web/features/buckets/components/bucket-card.tsx similarity index 77% rename from apps/web/components/bucket-card.tsx rename to apps/web/features/buckets/components/bucket-card.tsx index 6b13b2a..1574d3e 100644 --- a/apps/web/components/bucket-card.tsx +++ b/apps/web/features/buckets/components/bucket-card.tsx @@ -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>; - 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 (
-
- {icon || "🚫"} -
+ {limit > 0 ? ( +
+
+
+ {icon || "?"} +
+
+ ) : ( +
+ {icon || "?"} +
+ )}
-
{bucket.name}
+
{bucket.name}
{bucket.description ? (
{bucket.description} @@ -67,7 +78,7 @@ export function BucketCard({ aria-label="Bucket actions" data-bucket-menu-button > - ⋯ + ... {isMenuOpen ? ( @@ -100,7 +111,6 @@ export function BucketCard({ {limit > 0 ? ( <> - {renderUsageBar(bucket)} {isExpanded ? (
{usageLabel}
diff --git a/apps/web/components/buckets-panel.tsx b/apps/web/features/buckets/components/buckets-panel.tsx similarity index 89% rename from apps/web/components/buckets-panel.tsx rename to apps/web/features/buckets/components/buckets-panel.tsx index 303c571..b229350 100644 --- a/apps/web/components/buckets-panel.tsx +++ b/apps/web/features/buckets/components/buckets-panel.tsx @@ -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 ( -
-
-
-
-
- ); + 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() { ); } + diff --git a/apps/web/hooks/use-buckets.ts b/apps/web/features/buckets/hooks/use-buckets.ts similarity index 100% rename from apps/web/hooks/use-buckets.ts rename to apps/web/features/buckets/hooks/use-buckets.ts diff --git a/apps/web/features/entries/components/entries-discard-modal.tsx b/apps/web/features/entries/components/entries-discard-modal.tsx new file mode 100644 index 0000000..e2e2544 --- /dev/null +++ b/apps/web/features/entries/components/entries-discard-modal.tsx @@ -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 ( +
+
{ + if (event.key === "Escape") onCancel(); + }} + role="dialog" + tabIndex={-1} + > +
Discard changes?
+

You have unsaved changes. Do you want to discard them?

+
+ + +
+
+
+ ); +} diff --git a/apps/web/features/entries/components/entries-filter-modal.tsx b/apps/web/features/entries/components/entries-filter-modal.tsx new file mode 100644 index 0000000..8fa1558 --- /dev/null +++ b/apps/web/features/entries/components/entries-filter-modal.tsx @@ -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>; + 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 ( +
+
event.stopPropagation()} + onKeyDown={event => { + if (event.key === "Escape") onClose(); + }} + role="dialog" + tabIndex={-1} + > +
+

Filter Entries

+
+ +
+
+
+ + +
+ 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" } + ]} + /> +
+ +
+
+ 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} + /> +
+
+
+ {activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/features/entries/components/entries-list.tsx b/apps/web/features/entries/components/entries-list.tsx new file mode 100644 index 0000000..236e32c --- /dev/null +++ b/apps/web/features/entries/components/entries-list.tsx @@ -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 ( +
+ {!activeGroupId ? ( +
Select a group to view entries.
+ ) : loading ? ( +
+ {[0, 1, 2].map(row => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : 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 ( +
onOpenDetails(entry, index)} + > +
+
${entry.amountDollars.toFixed(2)}
+
+ {new Date(entry.occurredAt).toISOString().slice(0, 10)} - {entry.necessity} +
+
+ {tags.length ? ( + <> +
+ {mobileTags.map(tag => ( + + #{tag} + + ))} + {extraTagCount ? ( + + {extraTagCount} more... + + ) : null} +
+
+ {tags.map(tag => ( + + #{tag} + + ))} +
+ + ) : ( + No tags + )} +
+ ); + }) + ) : ( +
+
No matching entries.
+ {activeFilterCount ? ( + + ) : null} +
+ ) + ) : ( +
No entries yet.
+ )} +
+ ); +} diff --git a/apps/web/components/entries-panel.tsx b/apps/web/features/entries/components/entries-panel.tsx similarity index 52% rename from apps/web/components/entries-panel.tsx rename to apps/web/features/entries/components/entries-panel.tsx index 9cc45de..684e8d7 100644 --- a/apps/web/components/entries-panel.tsx +++ b/apps/web/features/entries/components/entries-panel.tsx @@ -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() {

Entries

-
- - -
+
-
- {!activeGroupId ? ( -
Select a group to view entries.
- ) : loading ? ( -
- {[0, 1, 2].map(row => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
- ) : 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 ( -
handleOpenDetails(entry, index)} - > -
-
${entry.amountDollars.toFixed(2)}
-
- {new Date(entry.occurredAt).toISOString().slice(0, 10)} · {entry.necessity} -
-
- {tags.length ? ( - <> -
- {mobileTags.map(tag => ( - - #{tag} - - ))} - {extraTagCount ? ( - - {extraTagCount} more... - - ) : null} -
-
- {tags.map(tag => ( - - #{tag} - - ))} -
- - ) : ( - No tags - )} -
- ); - }) - ) : ( -
-
No matching entries.
- {activeFilterCount ? ( - - ) : null} -
- ) - ) : ( -
No entries yet.
- )} -
+
1 ? "Loop" : ""} canNavigate={totalEntries > 1} /> - {filterOpen ? ( -
setFilterOpen(false)}> -
event.stopPropagation()} - onKeyDown={event => { - if (event.key === "Escape") setFilterOpen(false); - }} - role="dialog" - tabIndex={-1} - > -
-

Filter Entries

-
- -
-
-
- - -
-
- {([ - { value: "ANY", label: "Any" }, - { value: "NECESSARY", label: "Necessary" }, - { value: "BOTH", label: "Both" }, - { value: "UNNECESSARY", label: "Unnecessary" } - ] as const).map(option => ( - - ))} -
-
- -
-
- - - -
- } - tags={filters.tags} - suggestions={tagSuggestions} - allowCustom={false} - onToggleTag={handleFilterToggleTag} - onAddTag={handleFilterAddTag} - emptySuggestionLabel={emptyTagActionLabel} - emptySuggestionDisabled={!canManageTags} - onEmptySuggestionClick={handleEmptyTagAction} - /> -
-
-
- {activeFilterCount ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` : "No filters applied"} -
-
- - -
-
-
-
- ) : null} - {discardOpen ? ( -
-
{ - if (event.key === "Escape") handleCancelDiscard(); - }} - role="dialog" - tabIndex={-1} - > -
Discard changes?
-

You have unsaved changes. Do you want to discard them?

-
- - -
-
-
- ) : null} + setFilterOpen(false)} + /> + ); } + + diff --git a/apps/web/hooks/use-entries.ts b/apps/web/features/entries/hooks/use-entries.ts similarity index 100% rename from apps/web/hooks/use-entries.ts rename to apps/web/features/entries/hooks/use-entries.ts diff --git a/apps/web/features/groups/README.md b/apps/web/features/groups/README.md new file mode 100644 index 0000000..1a6dce3 --- /dev/null +++ b/apps/web/features/groups/README.md @@ -0,0 +1,3 @@ +# Groups Feature + +Reserved for groups domain modules (components/hooks/lib) during incremental migration. diff --git a/apps/web/hooks/use-group-audit.ts b/apps/web/features/groups/hooks/use-group-audit.ts similarity index 100% rename from apps/web/hooks/use-group-audit.ts rename to apps/web/features/groups/hooks/use-group-audit.ts diff --git a/apps/web/hooks/use-group-invites.ts b/apps/web/features/groups/hooks/use-group-invites.ts similarity index 100% rename from apps/web/hooks/use-group-invites.ts rename to apps/web/features/groups/hooks/use-group-invites.ts diff --git a/apps/web/hooks/use-group-members.ts b/apps/web/features/groups/hooks/use-group-members.ts similarity index 100% rename from apps/web/hooks/use-group-members.ts rename to apps/web/features/groups/hooks/use-group-members.ts diff --git a/apps/web/hooks/use-group-settings.ts b/apps/web/features/groups/hooks/use-group-settings.ts similarity index 100% rename from apps/web/hooks/use-group-settings.ts rename to apps/web/features/groups/hooks/use-group-settings.ts diff --git a/apps/web/hooks/use-groups.ts b/apps/web/features/groups/hooks/use-groups.ts similarity index 100% rename from apps/web/hooks/use-groups.ts rename to apps/web/features/groups/hooks/use-groups.ts diff --git a/apps/web/hooks/use-invite-link.ts b/apps/web/features/groups/hooks/use-invite-link.ts similarity index 100% rename from apps/web/hooks/use-invite-link.ts rename to apps/web/features/groups/hooks/use-invite-link.ts diff --git a/apps/web/features/tags/README.md b/apps/web/features/tags/README.md new file mode 100644 index 0000000..ebc5d54 --- /dev/null +++ b/apps/web/features/tags/README.md @@ -0,0 +1,3 @@ +# Tags Feature + +Reserved for tags domain modules (components/hooks/lib) during incremental migration. diff --git a/apps/web/hooks/use-tags.ts b/apps/web/features/tags/hooks/use-tags.ts similarity index 100% rename from apps/web/hooks/use-tags.ts rename to apps/web/features/tags/hooks/use-tags.ts diff --git a/apps/web/hooks/auth-context.tsx b/apps/web/hooks/auth-context.tsx index f05491c..f9ae382 100644 --- a/apps/web/hooks/auth-context.tsx +++ b/apps/web/hooks/auth-context.tsx @@ -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 | null>(null); @@ -19,3 +19,4 @@ export function useAuthContext() { if (!ctx) throw new Error("AuthProvider is missing"); return ctx; } + diff --git a/apps/web/hooks/groups-context.tsx b/apps/web/hooks/groups-context.tsx index 9262e4b..e5f794a 100644 --- a/apps/web/hooks/groups-context.tsx +++ b/apps/web/hooks/groups-context.tsx @@ -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 | null>(null); @@ -19,3 +19,4 @@ export function useGroupsContext() { if (!ctx) throw new Error("GroupsProvider is missing"); return ctx; } + diff --git a/apps/web/hooks/use-recurring-entries.ts b/apps/web/hooks/use-recurring-entries.ts deleted file mode 100644 index ad28617..0000000 --- a/apps/web/hooks/use-recurring-entries.ts +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - function isError(result: ApiResult): 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 - }; -} diff --git a/apps/web/lib/client/entry-mutation-events.ts b/apps/web/lib/client/entry-mutation-events.ts deleted file mode 100644 index 009f60b..0000000 --- a/apps/web/lib/client/entry-mutation-events.ts +++ /dev/null @@ -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(ENTRY_MUTATED_EVENT, { detail })); -} diff --git a/apps/web/lib/server/errors.ts b/apps/web/lib/server/errors.ts index bd384a0..18c1394 100644 --- a/apps/web/lib/server/errors.ts +++ b/apps/web/lib/server/errors.ts @@ -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 { diff --git a/apps/web/lib/server/rate-limit.ts b/apps/web/lib/server/rate-limit.ts index 40636ba..fc8c53e 100644 --- a/apps/web/lib/server/rate-limit.ts +++ b/apps/web/lib/server/rate-limit.ts @@ -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 | 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) }); diff --git a/apps/web/lib/server/request.ts b/apps/web/lib/server/request.ts index 26ee301..6034364 100644 --- a/apps/web/lib/server/request.ts +++ b/apps/web/lib/server/request.ts @@ -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") }; } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/apps/web/shared/README.md b/apps/web/shared/README.md new file mode 100644 index 0000000..3ac439f --- /dev/null +++ b/apps/web/shared/README.md @@ -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. diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index de6410b..69df9e0 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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; diff --git a/docker/nginx/fiddy.conf b/docker/nginx/fiddy.conf index ce8a571..9d763aa 100644 --- a/docker/nginx/fiddy.conf +++ b/docker/nginx/fiddy.conf @@ -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; diff --git a/docker/nginx/includes/fiddy-proxy.conf b/docker/nginx/includes/fiddy-proxy.conf index 9673dee..a306eba 100644 --- a/docker/nginx/includes/fiddy-proxy.conf +++ b/docker/nginx/includes/fiddy-proxy.conf @@ -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; diff --git a/docs/03_REFACTOR_1.md b/docs/03_REFACTOR_1.md new file mode 100644 index 0000000..822baea --- /dev/null +++ b/docs/03_REFACTOR_1.md @@ -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//...` 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//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//{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). + + + + diff --git a/docs/05_REFACTOR_2.md b/docs/05_REFACTOR_2.md index a49109b..13c9c23 100644 --- a/docs/05_REFACTOR_2.md +++ b/docs/05_REFACTOR_2.md @@ -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. diff --git a/docs/app_dev_plan/08_spendings_crud.md b/docs/app_dev_plan/08_spendings_crud.md index 8f12d6e..55910d3 100644 --- a/docs/app_dev_plan/08_spendings_crud.md +++ b/docs/app_dev_plan/08_spendings_crud.md @@ -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` diff --git a/docs/app_dev_plan/09_hooks_api_calls.md b/docs/app_dev_plan/09_hooks_api_calls.md index 87699d9..c960796 100644 --- a/docs/app_dev_plan/09_hooks_api_calls.md +++ b/docs/app_dev_plan/09_hooks_api_calls.md @@ -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`