checkpoint: commit remaining in-progress refactor changes

This commit is contained in:
Nico 2026-02-14 00:58:54 -08:00
parent 1b7e4b94b5
commit 3ee1a87d58
51 changed files with 1302 additions and 868 deletions

28
.codex/config.toml Normal file
View 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

View File

@ -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: its 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 (dont 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 theres a strong reason.
### Domain Blueprint (Consistency Rule)
For any new feature/domain, prefer:
- `app/api/<domain>/...`
- `lib/server/<domain>.ts`
- `lib/client/<domain>.ts`
- `hooks/use-<domain>.ts`
- `components/<domain>/*`
- `__tests__/<domain>.test.ts`
### API Conventions
- Prefer consistent JSON response shape for errors:
- `{ error: { code: string, message: string } }`
- Validate inputs at the route boundary (basic shape/type), and validate authorization in server services.
- When adding endpoints, mirror existing REST style used in the project.
### Non-Regression Contracts (Do Not Break)
- Entries list endpoints must **never** include receipt image bytes; image bytes are fetched via a separate endpoint only.
- Auth is DB-backed HttpOnly sessions; all auth checks are server-side.
- Groups require server-side membership checks; active group persists per user.
- Group invite codes:
- shown once immediately after group creation
- modal renders outside navbar/header so it overlays the viewport correctly
- avoid re-exposing invite code elsewhere without explicit “group settings” work
### UI Structure
- Page files stay thin; heavy logic stays in hooks/services/components.
- Dark mode, minimal, mobile-first.
- Dodger Blue accent (#1E90FF).
- Navbar: left nav dropdown, middle group selector, right user menu.
### Environment
- Dev and Prod must use the same schema/migrations (`packages/db/migrations`).
- `DATABASE_URL` points to external Postgres (NOT a container).
- `DATABASE_URL` format must be a full connection string; URL-encode special chars in passwords.
### Tests (Required)
- Add/update tests for API behavior changes:
- auth
- groups
- entries (group scoping)
- Tests must include negative cases: unauthorized, not-a-member, invalid inputs.
- Navbar layout: left nav dropdown, middle group selector, right user menu.

44
AGENTS.md Normal file
View 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; dont invent)
1) API routes: `app/api/**/route.ts`
- Thin: parse/validate + call service, return JSON.
2) Server services: `lib/server/*`
- Own DB + authz. Must include `import "server-only";`.
3) Client wrappers: `lib/client/*`
- Typed fetch + error normalization; always send credentials.
4) Hooks: `hooks/use-*.ts`
- Primary UI-facing API layer; components avoid raw `fetch()`.
## Next.js dynamic route params (required)
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
- `const { id } = await context.params;`
## Working style
- Scan repo first; dont guess file names or patterns.
- Make the smallest change that resolves the issue.
- Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake).

View File

@ -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

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthContext } from "@/hooks/auth-context";
import useInviteLink from "@/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>
);
}

View 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>
);
}

View File

@ -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";

View 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>
);
}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>
);
}

View 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>
);
}

View File

@ -5,7 +5,7 @@ test("group dropdown lists seeded groups", async ({ page }) => {
await login(page, "admin1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/");
const dropdown = page.getByRole("button", { name: /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();

View File

@ -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/);
});

View 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.

View File

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

View File

@ -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>

View File

@ -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() {
</>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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() {
</>
);
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
"use client";
import { createContext, useContext } from "react";
import useAuth from "@/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;
}

View File

@ -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;
}

View File

@ -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
};
}

View File

@ -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 }));
}

View File

@ -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 {

View File

@ -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)
});

View File

@ -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")
};
}

View File

@ -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.

View 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.

View File

@ -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;

View File

@ -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;

View File

@ -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
View 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).

View File

@ -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.

View File

@ -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`

View File

@ -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`