feat: implement schedules pivot, scheduler service, and dokploy deploy flow
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled

This commit is contained in:
Nico 2026-02-15 17:10:58 -08:00
parent 19ee02ac6c
commit f8e426542d
78 changed files with 4112 additions and 1137 deletions

View File

@ -33,11 +33,20 @@ jobs:
run: | run: |
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:main -f docker/Dockerfile . docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:main -f docker/Dockerfile .
- name: Build Scheduler Image
run: |
docker build -t $REGISTRY/scheduler:${{ github.sha }} -t $REGISTRY/scheduler:main -f docker/Dockerfile.scheduler .
- name: Push Web Image - name: Push Web Image
run: | run: |
docker push $REGISTRY/web:${{ github.sha }} docker push $REGISTRY/web:${{ github.sha }}
docker push $REGISTRY/web:main docker push $REGISTRY/web:main
- name: Push Scheduler Image
run: |
docker push $REGISTRY/scheduler:${{ github.sha }}
docker push $REGISTRY/scheduler:main
deploy: deploy:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -58,6 +67,19 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}" -d "{\"imageTag\":\"$IMAGE_TAG\"}"
- name: Trigger Dokploy Scheduler Deploy
env:
DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }}
IMAGE_TAG: ${{ github.sha }}
run: |
if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then
echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger"
exit 0
fi
curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \
-H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
- name: Wait for Ready Health Check - name: Wait for Ready Health Check
env: env:
HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }} HEALTH_URL: ${{ secrets.DOKPLOY_HEALTHCHECK_URL }}

View File

@ -1,69 +0,0 @@
name: Build & Deploy Fiddy
on:
push:
branches: [ "main" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/fiddy
IMAGE_TAG: main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build Web Image
run: |
docker build -t $REGISTRY/web:${{ github.sha }} -t $REGISTRY/web:${{ env.IMAGE_TAG }} -f docker/Dockerfile .
- name: Push Web Image
run: |
docker push $REGISTRY/web:${{ github.sha }}
docker push $REGISTRY/web:${{ env.IMAGE_TAG }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Upload docker-compose.yml
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/fiddy"
scp docker-compose.yml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/fiddy/docker-compose.yml
- name: Deploy via SSH
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
cd /opt/fiddy
export IMAGE_TAG=main
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
EOF

View File

@ -42,3 +42,12 @@
- Keep touched files free of TS warnings and lint errors. - Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases). - Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake). - Keep text encoding clean (no mojibake).
## Response icon legend
Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)":
- `🔄` in progress
- `✅` completed
- `🧪` verification/test result
- `⚠️` risk/blocker/manual action
- `❌` failure
- `🧭` recommendation/next step

View File

@ -17,6 +17,7 @@ If anything conflicts, follow **this** doc.
### External DB + migrations ### External DB + migrations
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container). - `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
- Dev/Prod share schema via migrations in: `packages/db/migrations`. - Dev/Prod share schema via migrations in: `packages/db/migrations`.
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
### No background jobs ### No background jobs
- **No cron/worker jobs**. Any fix must work without background tasks. - **No cron/worker jobs**. Any fix must work without background tasks.
@ -164,3 +165,25 @@ For `app/api/**/[param]/route.ts`:
- unauthorized - unauthorized
- not-a-member - not-a-member
- invalid input - invalid input
---
## 11) Agent Response Legend (required)
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
Legend:
- `🔄` in progress
- `✅` completed
- `🧪` test/lint/verification result
- `📄` documentation update
- `🗄️` database or migration change
- `🚀` deploy/release step
- `⚠️` risk, blocker, or manual operator action needed
- `❌` failed command or unsuccessful attempt
- `` informational context
- `🧭` recommendation or next-step option
Usage rules:
- Include at least one status icon in each substantive agent response.
- Use one icon per bullet/line; avoid icon spam.
- Keep icon meaning consistent with this legend.

View File

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

168
apps/scheduler/src/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,11 +56,9 @@ test("recurring entries list", async t => {
purchaseType: "Rent", purchaseType: "Rent",
notes: "Monthly rent", notes: "Monthly rent",
tags: ["rent"], tags: ["rent"],
isRecurring: true,
frequency: "MONTHLY", frequency: "MONTHLY",
intervalCount: 1, intervalCount: 1,
endCondition: "NEVER", endCondition: "NEVER"
nextRunAt: "2026-02-01"
}); });
const list = await listRecurringEntries(groupId); const list = await listRecurringEntries(groupId);

View File

@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { createSchedule, deleteSchedule, listSchedules, updateSchedule } from "../lib/server/schedules";
import { ensureTagsForGroup } from "../lib/server/tags";
import { cleanupTestData, uniqueInviteCode } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("schedules CRUD", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
let groupId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`schedule_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const groupRes = await client.query(
"insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id",
["Schedules Test", uniqueInviteCode("Q"), userId]
);
groupId = groupRes.rows[0].id as number;
await client.query(
"insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_ADMIN')",
[groupId, userId]
);
await ensureTagsForGroup({ userId, groupId, tags: ["rent", "home"] });
const created = await createSchedule({
groupId,
userId,
entryType: "SPENDING",
amountDollars: 1200,
necessity: "NECESSARY",
purchaseType: "Rent",
notes: "Monthly rent",
tags: ["rent", "home"],
startsOn: "2026-03-01",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
createEntryNow: false
});
const listed = await listSchedules(groupId);
assert.equal(listed.length, 1);
assert.equal(listed[0].id, created.id);
assert.equal(listed[0].frequency, "MONTHLY");
assert.deepEqual(listed[0].tags.sort(), ["home", "rent"]);
const updated = await updateSchedule({
id: created.id,
groupId,
userId,
entryType: "SPENDING",
amountDollars: 1300,
necessity: "NECESSARY",
purchaseType: "Rent",
notes: "Updated rent",
tags: ["rent"],
startsOn: "2026-03-01",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "AFTER_COUNT",
endCount: 3,
nextRunOn: "2026-04-01",
isActive: true
});
assert.ok(updated);
assert.equal(updated?.amountDollars, 1300);
assert.equal(updated?.endCondition, "AFTER_COUNT");
assert.equal(updated?.endCount, 3);
assert.deepEqual(updated?.tags, ["rent"]);
await deleteSchedule({ id: created.id, groupId, userId });
const afterDelete = await listSchedules(groupId);
assert.equal(afterDelete.length, 0);
} finally {
await cleanupTestData(client, { userIds: [userId], groupId });
client.release();
}
});

View File

@ -55,9 +55,7 @@ test("entries CRUD", async t => {
necessity: "NECESSARY", necessity: "NECESSARY",
purchaseType: "Groceries", purchaseType: "Groceries",
notes: "Test", notes: "Test",
tags: ["groceries", "weekly"], tags: ["groceries", "weekly"]
isRecurring: false,
intervalCount: 1
}); });
const list = await listEntries(groupId); const list = await listEntries(groupId);
@ -76,12 +74,7 @@ test("entries CRUD", async t => {
necessity: "BOTH", necessity: "BOTH",
purchaseType: "Groceries", purchaseType: "Groceries",
notes: "Updated", notes: "Updated",
tags: ["groceries"], tags: ["groceries"]
isRecurring: true,
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
nextRunAt: "2026-02-02"
}); });
assert.ok(updated); assert.ok(updated);
assert.equal(updated?.amountDollars, 15); assert.equal(updated?.amountDollars, 15);

View File

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

View File

@ -0,0 +1,45 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import getPool from "../lib/server/db";
import { getUserSettings, setUserSettings } from "../lib/server/user-settings";
import { cleanupTestData } from "./test-helpers";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envLoaded = dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
const hasDb = Boolean(process.env.DATABASE_URL);
test("user settings default and update", async t => {
if (!hasDb) {
t.skip("DATABASE_URL not set");
return;
}
if (envLoaded.error) t.diagnostic(String(envLoaded.error));
const pool = getPool();
const client = await pool.connect();
let userId: number | null = null;
try {
const userRes = await client.query(
"insert into users(email, password_hash) values($1,$2) returning id",
[`user_settings_${Date.now()}@example.com`, "hash"]
);
userId = userRes.rows[0].id as number;
const initial = await getUserSettings(userId);
assert.equal(initial.entryPanelPageSize, 10);
const updated = await setUserSettings({ userId, entryPanelPageSize: 25 });
assert.equal(updated.entryPanelPageSize, 25);
const readBack = await getUserSettings(userId);
assert.equal(readBack.entryPanelPageSize, 25);
} finally {
await cleanupTestData(client, { userIds: [userId] });
client.release();
}
});

View File

@ -27,13 +27,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim(); const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase(); const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const isRecurring = Boolean(body?.isRecurring);
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
@ -44,13 +37,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType)) if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await updateEntry({ const entry = await updateEntry({
id, id,
groupId, groupId,
@ -62,13 +48,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
purchaseType, purchaseType,
notes: notes || undefined, notes: notes || undefined,
tags, tags,
isRecurring,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId bucketId
}); });

View File

@ -36,13 +36,6 @@ export async function POST(req: Request) {
const purchaseType = String(body?.purchaseType || tags[0] || "General").trim(); const purchaseType = String(body?.purchaseType || tags[0] || "General").trim();
const notes = String(body?.notes || "").trim(); const notes = String(body?.notes || "").trim();
const entryType = String(body?.entryType || "SPENDING").toUpperCase(); const entryType = String(body?.entryType || "SPENDING").toUpperCase();
const isRecurring = Boolean(body?.isRecurring);
const frequency = body?.frequency ? String(body.frequency).toUpperCase() : null;
const intervalCount = Number(body?.intervalCount || 1);
const endCondition = body?.endCondition ? String(body.endCondition).toUpperCase() : null;
const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : (isRecurring ? occurredAt : null);
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null; const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
@ -53,13 +46,6 @@ export async function POST(req: Request) {
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_NECESSITY", message: "Invalid necessity" } }, { status: 400 });
if (!['SPENDING', 'INCOME'].includes(entryType)) if (!['SPENDING', 'INCOME'].includes(entryType))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
const entry = await createEntry({ const entry = await createEntry({
groupId, groupId,
userId: user.id, userId: user.id,
@ -70,13 +56,6 @@ export async function POST(req: Request) {
purchaseType, purchaseType,
notes: notes || undefined, notes: notes || undefined,
tags, tags,
isRecurring,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount,
endDate,
nextRunAt,
bucketId bucketId
}); });

View File

@ -33,7 +33,6 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const endCount = body?.endCount != null ? Number(body.endCount) : null; const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null; const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt; const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
@ -45,7 +44,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0) if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
@ -61,14 +60,12 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
purchaseType, purchaseType,
notes: notes || undefined, notes: notes || undefined,
tags, tags,
isRecurring: true, frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount, intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount, endCount,
endDate, endDate,
nextRunAt, nextRunAt
bucketId
}); });
if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); if (!entry) return NextResponse.json({ requestId, request_id: requestId, error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 });

View File

@ -1,4 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSessionUser } from "@/lib/server/session"; import { requireSessionUser } from "@/lib/server/session";
import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries"; import { createRecurringEntry, listRecurringEntries, requireActiveGroup } from "@/lib/server/recurring-entries";
import { toErrorResponse } from "@/lib/server/errors"; import { toErrorResponse } from "@/lib/server/errors";
@ -42,7 +42,6 @@ export async function POST(req: Request) {
const endCount = body?.endCount != null ? Number(body.endCount) : null; const endCount = body?.endCount != null ? Number(body.endCount) : null;
const endDate = body?.endDate ? String(body.endDate) : null; const endDate = body?.endDate ? String(body.endDate) : null;
const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt; const nextRunAt = body?.nextRunAt ? String(body.nextRunAt) : occurredAt;
const bucketId = body?.bucketId != null ? Number(body.bucketId) : null;
if (!Number.isFinite(amountDollars) || amountDollars <= 0) if (!Number.isFinite(amountDollars) || amountDollars <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_AMOUNT", message: "Invalid amount" } }, { status: 400 });
@ -54,7 +53,7 @@ export async function POST(req: Request) {
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_ENTRY_TYPE", message: "Invalid entryType" } }, { status: 400 });
if (!Number.isFinite(intervalCount) || intervalCount <= 0) if (!Number.isFinite(intervalCount) || intervalCount <= 0)
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_INTERVAL", message: "Invalid intervalCount" } }, { status: 400 });
if (frequency && !['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'].includes(frequency)) if (frequency && !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(frequency))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_FREQUENCY", message: "Invalid frequency" } }, { status: 400 });
if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition)) if (endCondition && !['NEVER', 'AFTER_COUNT', 'BY_DATE'].includes(endCondition))
return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 }); return NextResponse.json({ requestId, request_id: requestId, error: { code: "INVALID_END_CONDITION", message: "Invalid endCondition" } }, { status: 400 });
@ -69,14 +68,11 @@ export async function POST(req: Request) {
purchaseType, purchaseType,
notes: notes || undefined, notes: notes || undefined,
tags, tags,
isRecurring: true, frequency: frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null,
frequency: frequency as "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null,
intervalCount, intervalCount,
endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null, endCondition: endCondition as "NEVER" | "AFTER_COUNT" | "BY_DATE" | null,
endCount, endCount,
endDate, endDate
nextRunAt,
bucketId
}); });
return NextResponse.json({ requestId, request_id: requestId, entry }); return NextResponse.json({ requestId, request_id: requestId, entry });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,12 +13,6 @@ export type EntryDetailsForm = {
notes: string; notes: string;
tags: string[]; tags: string[];
entryType: "SPENDING" | "INCOME"; entryType: "SPENDING" | "INCOME";
isRecurring: boolean;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: string;
endDate: string;
}; };
type EntryDetailsModalProps = { type EntryDetailsModalProps = {
@ -83,7 +77,6 @@ export default function EntryDetailsModal({
const notesChanged = form.notes !== baseline.notes; const notesChanged = form.notes !== baseline.notes;
const changedInputClass = "border-2 border-[color:var(--color-accent)]"; const changedInputClass = "border-2 border-[color:var(--color-accent)]";
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
const touchStartX = useRef<number | null>(null); const touchStartX = useRef<number | null>(null);
const touchDeltaX = useRef(0); const touchDeltaX = useRef(0);
@ -119,10 +112,7 @@ export default function EntryDetailsModal({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div <div
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4" className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
@ -131,26 +121,14 @@ export default function EntryDetailsModal({
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
<div className="grid grid-cols-3 items-center gap-3"> <div className="grid grid-cols-3 items-center gap-3">
<button <button type="button" onClick={onPrev} className="flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50" disabled={!canNavigate} aria-label="Previous entry">
type="button" <span aria-hidden>{loopHintPrev ? "o" : "<"}</span>
onClick={onPrev}
className="flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Previous entry"
>
<span aria-hidden>{loopHintPrev ? "↺" : "←"}</span>
<span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span> <span className="text-xs text-soft">{loopHintPrev ? "To Last" : "Prev"}</span>
</button> </button>
<h2 className="text-center text-lg font-semibold">Entry Details</h2> <h2 className="text-center text-lg font-semibold">Entry Details</h2>
<button <button type="button" onClick={onNext} className="ml-auto flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50" disabled={!canNavigate} aria-label="Next entry">
type="button"
onClick={onNext}
className="ml-auto flex w-20 items-center justify-between rounded-lg border border-accent-weak bg-panel px-2.5 py-1 text-sm font-semibold text-muted hover:border-accent disabled:opacity-50"
disabled={!canNavigate}
aria-label="Next entry"
>
<span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span> <span className="text-xs text-soft">{loopHintNext ? "To First" : "Next"}</span>
<span aria-hidden>{loopHintNext ? "↻" : "→"}</span> <span aria-hidden>{loopHintNext ? "o" : ">"}</span>
</button> </button>
</div> </div>
<form <form
@ -165,8 +143,6 @@ export default function EntryDetailsModal({
}} }}
className="mt-3 grid gap-3 md:grid-cols-2" className="mt-3 grid gap-3 md:grid-cols-2"
> >
<div className="md:col-span-2 flex flex-wrap items-center gap-2"> <div className="md:col-span-2 flex flex-wrap items-center gap-2">
<ToggleButtonGroup <ToggleButtonGroup
value={form.entryType} value={form.entryType}
@ -178,21 +154,7 @@ export default function EntryDetailsModal({
{ value: "INCOME", label: "Income" } { value: "INCOME", label: "Income" }
]} ]}
/> />
<button
type="button"
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => onChange({ isRecurring: !form.isRecurring })}
title="Toggle Recurring Entry"
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
>
<span aria-hidden className="font-bold text-[25px]"
style={{ transform: `translateY(-2px)` }}
></span>
</button>
</div> </div>
<label className="text-sm text-muted"> <label className="text-sm text-muted">
Amount ($) Amount ($)
<input <input
@ -244,62 +206,6 @@ export default function EntryDetailsModal({
onEmptySuggestionClick={onEmptyTagAction} onEmptySuggestionClick={onEmptyTagAction}
invalid={!currentTags.length} invalid={!currentTags.length}
/> />
{form.isRecurring ? (
<div className="md:col-span-2">
<div className="text-sm text-muted">Frequency Conditions</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
<input
type="number"
min={1}
className="w-20 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="min-w-[120px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as EntryDetailsForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="BIWEEKLY">biweekly</option>
<option value="MONTHLY">month(s)</option>
<option value="QUARTERLY">quarter(s)</option>
<option value="YEARLY">year(s)</option>
</select>
<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" }
]}
/>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : null}
</div>
</div>
) : null}
<label className="text-sm text-muted md:col-span-2"> <label className="text-sm text-muted md:col-span-2">
Notes Notes
<textarea <textarea
@ -320,42 +226,16 @@ export default function EntryDetailsModal({
aria-label="Revert changes" aria-label="Revert changes"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed" className="flex h-9 w-9 items-center justify-center rounded-lg border border-accent-weak bg-panel text-sm hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
> >
<svg R
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v4h4" />
</svg>
</button> </button>
<button <button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed" disabled={!isDirty}>
type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35 disabled:cursor-not-allowed disabled:grayscale disabled:shadow-none"
disabled={!isDirty}
>
Save changes Save changes
</button> </button>
<button <button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
type="button"
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
onClick={onRequestDelete}
>
Delete Delete
</button> </button>
<div className="flex-1 w-full" /> <div className="flex-1 w-full" />
<button <button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" aria-label="Close">
type="button"
onClick={onClose}
className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold"
aria-label="Close"
>
Close Close
</button> </button>
</div> </div>

View File

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

View File

@ -25,9 +25,11 @@ type NewBucketModalProps = {
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void; onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<BucketForm>) => void; onChange: (next: Partial<BucketForm>) => void;
tagSuggestions: string[]; tagSuggestions: string[];
canDelete?: boolean;
onDelete?: () => void;
}; };
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions }: NewBucketModalProps) { export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
const [iconModalOpen, setIconModalOpen] = useState(false); const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconSearch, setIconSearch] = useState(""); const [iconSearch, setIconSearch] = useState("");
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
@ -157,11 +159,22 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })} onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
onAddTag={tag => onChange({ tags: [...form.tags, tag] })} onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
/> />
<div className="md:col-span-2 flex items-center justify-between"> <div className="md:col-span-2 flex items-center justify-between gap-3">
{error ? <div className="text-sm text-red-400">{error}</div> : null}
<div className="ml-auto flex items-center gap-2">
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"> <button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
Save bucket Save bucket
</button> </button>
{error ? <div className="text-sm text-red-400">{error}</div> : null} {canDelete ? (
<button
type="button"
className="rounded-lg border border-red-400/70 bg-red-500/10 px-4 py-2 text-sm font-semibold text-red-200 hover:bg-red-500/15"
onClick={onDelete}
>
Delete bucket
</button>
) : null}
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@ -13,12 +13,6 @@ type NewEntryForm = {
notes: string; notes: string;
tags: string[]; tags: string[];
entryType: "SPENDING" | "INCOME"; entryType: "SPENDING" | "INCOME";
isRecurring: boolean;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: string;
endDate: string;
}; };
type NewEntryModalProps = { type NewEntryModalProps = {
@ -37,7 +31,6 @@ type NewEntryModalProps = {
}; };
export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) { export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit, onChange, tagSuggestions, emptyTagActionLabel, emptyTagActionDisabled = false, onEmptyTagAction, amountInputRef, tagsInputRef }: NewEntryModalProps) {
const recurrenceLabel = form.isRecurring ? "Recurring" : "One-Time";
const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense"; const typeLabel = form.entryType === "INCOME" ? "Income" : "Expense";
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
@ -49,12 +42,12 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
if (!form.occurredAt) { if (!form.occurredAt) {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
onChange({ occurredAt: today, endDate: form.endDate || today }); onChange({ occurredAt: today });
} }
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [form.endDate, form.occurredAt, isOpen, onChange, onClose]); }, [form.occurredAt, isOpen, onChange, onClose]);
if (!isOpen) return null; if (!isOpen) return null;
@ -68,14 +61,14 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">New {recurrenceLabel} {typeLabel} Entry</h2> <h2 className="text-lg font-semibold">New {typeLabel} Entry</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-lg btn-outline-accent px-2 py-1 text-sm" className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close" aria-label="Close"
> >
x
</button> </button>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
@ -90,18 +83,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
{ value: "INCOME", label: "Income" } { value: "INCOME", label: "Income" }
]} ]}
/> />
<button
type="button"
className={`flex h-9 w-9 items-center justify-center rounded-full border text-lg ${form.isRecurring ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => onChange({ isRecurring: !form.isRecurring })}
title="Toggle Recurring Entry"
aria-label={form.isRecurring ? "Recurring entry" : "One-time entry"}
>
<span aria-hidden className="font-bold text-[25px]"
style={{ transform: `translateY(-2px)` }}
></span>
</button>
</div> </div>
<form <form
ref={formRef} ref={formRef}
@ -116,10 +97,9 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
className="mt-3 grid gap-3 md:grid-cols-2" className="mt-3 grid gap-3 md:grid-cols-2"
> >
<label className="text-sm text-muted"> <label className="text-sm text-muted">
{/* Amount ($) */}
<div className="relative mt-1"> <div className="relative mt-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft"> <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-soft">
{form.entryType === "INCOME" ? "🤑 $" : "😭 $"} {form.entryType === "INCOME" ? "$" : "$"}
</span> </span>
<input <input
ref={amountInputRef} ref={amountInputRef}
@ -157,8 +137,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
]} ]}
/> />
</div> </div>
{/* TAGS */}
<TagInput <TagInput
label="Tags" label="Tags"
tags={form.tags} tags={form.tags}
@ -172,62 +150,6 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
invalid={!form.tags.length} invalid={!form.tags.length}
inputRef={tagsInputRef} inputRef={tagsInputRef}
/> />
{/* RECURRING OPTIONS */}
{form.isRecurring ? (
<div className="md:col-span-2">
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
<div className="text-sm text-muted mr-2">Every</div>
<input
type="number"
min={1}
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as NewEntryForm["frequency"] })}
>
<option value="DAILY">day(s)</option>
<option value="WEEKLY">week(s)</option>
<option value="MONTHLY">month(s)</option>
<option value="YEARLY">year(s)</option>
</select>
<ToggleButtonGroup
value={form.endCondition}
onChange={endCondition => onChange({ endCondition })}
ariaLabel="End condition"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-3 text-xs font-semibold"
options={[
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
]}
/>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : null}
</div>
</div>
) : null}
<label className="text-sm text-muted md:col-span-2"> <label className="text-sm text-muted md:col-span-2">
Notes Notes
<textarea <textarea
@ -244,7 +166,7 @@ export default function NewEntryModal({ isOpen, form, error, onClose, onSubmit,
type="submit" type="submit"
className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold"
> >
{form.isRecurring ? "Set schedule and add entry" : "Add entry"} Add entry
</button> </button>
{error ? <div className="text-sm text-red-400">{error}</div> : null} {error ? <div className="text-sm text-red-400">{error}</div> : null}
</div> </div>

View File

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

View File

@ -0,0 +1,261 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
import DatePicker from "@/components/date-picker";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export type ScheduleDetailsForm = {
amountDollars: string;
startsOn: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
frequency: ScheduleFrequency;
intervalCount: number;
endCondition: ScheduleEndCondition;
endCount: string;
endDate: string;
nextRunOn: string;
isActive: boolean;
};
type ScheduleDetailsModalProps = {
isOpen: boolean;
form: ScheduleDetailsForm;
originalForm: ScheduleDetailsForm | null;
isDirty: boolean;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onRequestDelete: () => void;
onRevert: () => void;
onChange: (next: Partial<ScheduleDetailsForm>) => void;
onAddTag: (tag: string) => void;
onToggleTag: (tag: string) => void;
removedTags: string[];
tagSuggestions: string[];
emptyTagActionLabel?: string;
emptyTagActionDisabled?: boolean;
onEmptyTagAction?: () => void;
};
export default function ScheduleDetailsModal({
isOpen,
form,
originalForm,
isDirty,
error,
onClose,
onSubmit,
onRequestDelete,
onRevert,
onChange,
onAddTag,
onToggleTag,
removedTags,
tagSuggestions,
emptyTagActionLabel,
emptyTagActionDisabled = false,
onEmptyTagAction
}: ScheduleDetailsModalProps) {
const baseline = originalForm ?? form;
const removedSet = new Set(removedTags.map(tag => tag.toLowerCase()));
const currentTags = form.tags.filter(tag => !removedSet.has(tag.toLowerCase()));
const normalizeTags = (tags: string[]) => tags.map(tag => tag.toLowerCase()).sort().join("|");
const baselineTags = baseline.tags || [];
const addedTags = currentTags.filter(tag => !baselineTags.some(base => base.toLowerCase() === tag.toLowerCase()));
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={onClose}>
<div className="w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Schedule Details</h2>
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close">
x
</button>
</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<div className="md:col-span-2 flex items-center gap-2">
<ToggleButtonGroup
value={form.entryType}
onChange={entryType => onChange({ entryType })}
ariaLabel="Entry type"
sizeClassName="px-4 py-2.5 text-xs font-semibold"
options={[
{ value: "SPENDING", label: "Spending" },
{ value: "INCOME", label: "Income" }
]}
/>
<ToggleButtonGroup
value={form.isActive ? "ACTIVE" : "PAUSED"}
onChange={value => onChange({ isActive: value === "ACTIVE" })}
ariaLabel="Schedule active status"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ACTIVE", label: "Active" },
{ value: "PAUSED", label: "Paused" }
]}
/>
</div>
<label className="text-sm text-muted">
Amount ($)
<input
name="amountDollars"
type="number"
min={0}
step="0.01"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={form.amountDollars}
onChange={e => onChange({ amountDollars: e.target.value })}
required
/>
</label>
<div className="text-sm text-muted">
<DatePicker name="startsOn" value={form.startsOn} onChange={startsOn => onChange({ startsOn })} required className="mt-1" />
</div>
<div className="text-sm text-muted">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
</div>
<TagInput
label="Tags"
tags={form.tags}
removedTags={removedTags}
highlightTags={addedTags}
suggestions={tagSuggestions}
allowCustom={false}
chipsBelow
onToggleTag={onToggleTag}
onAddTag={onAddTag}
emptySuggestionLabel={emptyTagActionLabel}
emptySuggestionDisabled={emptyTagActionDisabled}
onEmptySuggestionClick={onEmptyTagAction}
invalid={!currentTags.length}
/>
<div className="md:col-span-2">
<div className="mt-2 flex flex-wrap items-center justify-center gap-y-2">
<div className="text-sm text-muted mr-2">Every</div>
<input
type="number"
min={1}
className="mr-1 w-12 input-base px-3 py-2 text-center text-sm"
value={form.intervalCount}
onChange={e => onChange({ intervalCount: Number(e.target.value || 1) })}
/>
<select
className="min-w-[100px] input-base px-3 py-2 text-center text-sm"
value={form.frequency}
onChange={e => onChange({ frequency: e.target.value as ScheduleFrequency })}
>
<option value="DAILY">daily</option>
<option value="WEEKLY">weakly</option>
<option value="MONTHLY">monthly</option>
<option value="YEARLY">yearly</option>
</select>
<ToggleButtonGroup
value={form.endCondition}
onChange={endCondition => onChange({ endCondition })}
ariaLabel="End condition"
className="flex items-center gap-1 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-3 text-xs font-semibold"
options={[
{ value: "NEVER", label: "Forever" },
{ value: "BY_DATE", label: "Until" },
{ value: "AFTER_COUNT", label: "After" }
]}
/>
{form.endCondition === "AFTER_COUNT" ? (
<input
type="number"
min={1}
className="w-24 input-base px-3 py-2 text-center text-sm"
value={form.endCount}
placeholder="Count"
onChange={e => onChange({ endCount: e.target.value })}
/>
) : null}
{form.endCondition === "BY_DATE" ? (
<DatePicker
value={form.endDate}
onChange={endDate => onChange({ endDate })}
showWeekButtons={false}
centerInput
/>
) : null}
</div>
</div>
<div className="text-sm text-muted">
Next run
<DatePicker name="nextRunOn" value={form.nextRunOn} onChange={nextRunOn => onChange({ nextRunOn })} required className="mt-1" />
</div>
<label className="text-sm text-muted md:col-span-2">
Notes
<textarea
name="notes"
className="mt-1 w-full input-base px-3 py-2 text-sm"
rows={3}
value={form.notes}
onChange={e => onChange({ notes: e.target.value })}
placeholder="Optional"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between">
<div className="flex items-center gap-2 w-full">
<button type="button" onClick={onRevert} disabled={!isDirty} className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold disabled:opacity-40">
Revert
</button>
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold disabled:opacity-35" disabled={!isDirty}>
Save changes
</button>
<button type="button" className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold" onClick={onRequestDelete}>
Delete
</button>
<div className="flex-1 w-full" />
<button type="button" onClick={onClose} className="rounded-lg btn-outline-accent px-4 py-2 text-sm font-semibold">
Close
</button>
</div>
{error ? <div className="text-sm text-red-400">{error}</div> : null}
</div>
</form>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -5,7 +5,7 @@ test("seeded entries render with tags and no-tag state", async ({ page }) => {
await login(page, "owner1@fiddy.dev", "FiddyDev123!"); await login(page, "owner1@fiddy.dev", "FiddyDev123!");
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
await expect(page.getByRole("heading", { name: "Entries" })).toBeVisible(); await expect(page.getByRole("button", { name: "Entries" })).toBeVisible();
await expect(page.getByText("$12.50").first()).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: "#Food" }).first()).toBeVisible();
await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible(); await expect(page.locator("span:visible", { hasText: "#Travel" }).first()).toBeVisible();

View File

@ -1,27 +1,122 @@
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import type { Bucket } from "@/lib/client/buckets"; import type { Bucket } from "@/lib/client/buckets";
type BucketCardProps = { type BucketCardProps = {
bucket: Bucket; bucket: Bucket;
icon?: string | null; 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; openEdit: (bucketId: number) => void;
limit: number; limit: number;
usageLabel: string; usageLabel: string;
}; };
const TAG_GAP_PX = 8;
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]";
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-[11px] text-soft";
function BucketTagsRow({ tags }: { tags: string[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLSpanElement | null>(null);
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [visibleCount, setVisibleCount] = useState(tags.length);
useEffect(() => {
tagRefs.current = tagRefs.current.slice(0, tags.length);
setVisibleCount(tags.length);
}, [tags]);
useEffect(() => {
if (!tags.length) return;
function recomputeVisibleCount() {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
const moreProbe = moreRef.current;
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
setVisibleCount(tags.length);
return;
}
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
if (totalTagsWidth <= containerWidth) {
setVisibleCount(tags.length);
return;
}
let nextVisibleCount = 0;
let usedWidth = 0;
for (let index = 0; index < widths.length; index += 1) {
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
const remaining = widths.length - (index + 1);
if (remaining <= 0) {
nextVisibleCount = widths.length;
break;
}
moreProbe.textContent = `${remaining} more...`;
const moreWidth = moreProbe.offsetWidth;
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
else break;
}
setVisibleCount(nextVisibleCount);
}
recomputeVisibleCount();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(recomputeVisibleCount);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}
window.addEventListener("resize", recomputeVisibleCount);
return () => window.removeEventListener("resize", recomputeVisibleCount);
}, [tags]);
if (!tags.length) {
return <span className="text-[11px] text-soft">No tags</span>;
}
const visibleTags = tags.slice(0, visibleCount);
const hasOverflow = visibleCount < tags.length;
const remainingCount = tags.length - visibleCount;
return (
<div className="relative w-full">
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleTags.map((tag, index) => (
<span key={`${tag}-${index}`} className={TAG_CLASS}>
#{tag}
</span>
))}
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
</div>
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
{tags.map((tag, index) => (
<span
key={`${tag}-${index}`}
ref={element => {
tagRefs.current[index] = element;
}}
className={`${TAG_CLASS} inline-block`}
>
#{tag}
</span>
))}
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
{tags.length} more...
</span>
</div>
</div>
);
}
export function BucketCard({ export function BucketCard({
bucket, bucket,
icon, icon,
isExpanded,
toggleExpanded,
isMenuOpen,
setMenuOpenId,
setConfirmDeleteId,
openEdit, openEdit,
limit, limit,
usageLabel, usageLabel,
@ -34,8 +129,8 @@ export function BucketCard({
return ( return (
<div <div
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent" className="w-full cursor-pointer rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
onClick={() => toggleExpanded(bucket.id)} onClick={() => openEdit(bucket.id)}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
@ -60,93 +155,20 @@ export function BucketCard({
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold">{bucket.name}</div> <div className="truncate text-sm font-semibold">{bucket.name}</div>
{bucket.description ? ( {bucket.description ? (
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}> <div className="text-xs text-soft">
{bucket.description} {bucket.description}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="relative" data-bucket-menu>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={(event) => {
event.stopPropagation();
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
}}
aria-label="Bucket actions"
data-bucket-menu-button
>
...
</button>
{isMenuOpen ? (
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
<button
type="button"
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
onClick={(event) => {
event.stopPropagation();
openEdit(bucket.id);
}}
>
Edit
</button>
<button
type="button"
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
onClick={(event) => {
event.stopPropagation();
setConfirmDeleteId(bucket.id);
}}
>
Delete
</button>
</div>
) : null}
</div>
</div> </div>
{limit > 0 ? (
<>
{isExpanded ? (
<div className="mt-2 space-y-2 text-xs text-soft"> <div className="mt-2 space-y-2 text-xs text-soft">
<div>{usageLabel}</div> {limit > 0 ? <div>{usageLabel}</div> : null}
<div className="flex flex-wrap gap-2"> <div className="flex min-w-0 items-center">
{bucket.tags?.length ? ( <BucketTagsRow tags={bucket.tags || []} />
bucket.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
>
#{tag}
</span>
))
) : (
<span className="text-[11px] text-soft">No tags</span>
)}
</div> </div>
</div> </div>
) : null}
</>
) : isExpanded ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
{bucket.tags?.length ? (
bucket.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
>
#{tag}
</span>
))
) : (
<span className="text-[11px] text-soft">No tags</span>
)}
</div>
) : null}
</div> </div>
); );
} }
@ -154,8 +176,6 @@ export function BucketCard({
export default React.memo(BucketCard, (prev, next) => ( export default React.memo(BucketCard, (prev, next) => (
prev.bucket === next.bucket prev.bucket === next.bucket
&& prev.icon === next.icon && prev.icon === next.icon
&& prev.isExpanded === next.isExpanded
&& prev.isMenuOpen === next.isMenuOpen
&& prev.limit === next.limit && prev.limit === next.limit
&& prev.usageLabel === next.usageLabel && prev.usageLabel === next.usageLabel
)); ));

View File

@ -9,17 +9,17 @@ import ConfirmSlideModal from "@/components/confirm-slide-modal";
import { bucketIcons } from "@/lib/shared/bucket-icons"; import { bucketIcons } from "@/lib/shared/bucket-icons";
import BucketCard from "./bucket-card"; import BucketCard from "./bucket-card";
import { useEntryMutation } from "@/hooks/entry-mutation-context"; import { useEntryMutation } from "@/hooks/entry-mutation-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function BucketsPanel() { export default function BucketsPanel() {
const { activeGroupId } = useGroupsContext(); const { activeGroupId } = useGroupsContext();
const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId); const { buckets, loading, error, createBucket, updateBucket, deleteBucket, reload } = useBuckets(activeGroupId);
const { mutationVersion } = useEntryMutation(); const { mutationVersion } = useEntryMutation();
const { notify } = useNotificationsContext();
const { tags: tagSuggestions } = useTags(activeGroupId); const { tags: tagSuggestions } = useTags(activeGroupId);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null); const [editId, setEditId] = useState<number | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [menuOpenId, setMenuOpenId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<number[]>([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", name: "",
description: "", description: "",
@ -33,18 +33,6 @@ export default function BucketsPanel() {
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []); const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]); const orderedBuckets = useMemo(() => [...buckets].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)), [buckets]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!menuOpenId) return;
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest("[data-bucket-menu]") || target.closest("[data-bucket-menu-button]")) return;
setMenuOpenId(null);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpenId]);
useEffect(() => { useEffect(() => {
if (!activeGroupId) return; if (!activeGroupId) return;
if (mutationVersion === 0) return; if (mutationVersion === 0) return;
@ -75,7 +63,6 @@ export default function BucketsPanel() {
windowDays: bucket.windowDays ? String(bucket.windowDays) : "30" windowDays: bucket.windowDays ? String(bucket.windowDays) : "30"
}); });
setModalOpen(true); setModalOpen(true);
setMenuOpenId(null);
} }
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
@ -97,7 +84,10 @@ export default function BucketsPanel() {
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays windowDays
}); });
if (ok) setModalOpen(false); if (ok) {
notify({ title: "Bucket updated", message: form.name.trim(), tone: "success" });
setModalOpen(false);
}
} else { } else {
const ok = await createBucket({ const ok = await createBucket({
name: form.name.trim(), name: form.name.trim(),
@ -108,14 +98,11 @@ export default function BucketsPanel() {
necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY", necessity: form.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
windowDays windowDays
}); });
if (ok) setModalOpen(false); if (ok) {
notify({ title: "Bucket created", message: form.name.trim(), tone: "success" });
setModalOpen(false);
} }
} }
function toggleExpanded(bucketId: number) {
setExpandedIds(prev => prev.includes(bucketId)
? prev.filter(id => id !== bucketId)
: [...prev, bucketId]);
} }
function budgetUsage(bucket: typeof buckets[number]) { function budgetUsage(bucket: typeof buckets[number]) {
@ -139,7 +126,7 @@ export default function BucketsPanel() {
+ +
</button> </button>
</div> </div>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="mt-3 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
{!activeGroupId ? ( {!activeGroupId ? (
@ -161,17 +148,11 @@ export default function BucketsPanel() {
orderedBuckets.map(bucket => { orderedBuckets.map(bucket => {
const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null; const icon = bucket.iconKey ? iconMap.get(bucket.iconKey) : null;
const { limit, spent } = budgetUsage(bucket); const { limit, spent } = budgetUsage(bucket);
const isExpanded = expandedIds.includes(bucket.id);
const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : ""; const usageLabel = limit ? `$${spent.toFixed(2)} / $${limit.toFixed(2)}` : "";
return <BucketCard return <BucketCard
key={bucket.id} key={bucket.id}
bucket={bucket} bucket={bucket}
icon={icon} icon={icon}
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
isMenuOpen={menuOpenId === bucket.id}
setMenuOpenId={setMenuOpenId}
setConfirmDeleteId={setConfirmDeleteId}
openEdit={openEdit} openEdit={openEdit}
limit={limit} limit={limit}
usageLabel={usageLabel} usageLabel={usageLabel}
@ -191,6 +172,11 @@ export default function BucketsPanel() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
onChange={next => setForm(prev => ({ ...prev, ...next }))} onChange={next => setForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions} tagSuggestions={tagSuggestions}
canDelete={Boolean(editId)}
onDelete={() => {
if (!editId) return;
setConfirmDeleteId(editId);
}}
/> />
<ConfirmSlideModal <ConfirmSlideModal
isOpen={Boolean(confirmDeleteId)} isOpen={Boolean(confirmDeleteId)}
@ -200,8 +186,18 @@ export default function BucketsPanel() {
onClose={() => setConfirmDeleteId(null)} onClose={() => setConfirmDeleteId(null)}
onConfirm={async () => { onConfirm={async () => {
if (!confirmDeleteId) return; if (!confirmDeleteId) return;
const deletedBucket = buckets.find(bucket => bucket.id === confirmDeleteId) || null;
const ok = await deleteBucket(confirmDeleteId); const ok = await deleteBucket(confirmDeleteId);
if (ok) setConfirmDeleteId(null); if (ok) {
notify({
title: "Bucket deleted",
message: deletedBucket?.name || "Bucket removed",
tone: "danger"
});
setConfirmDeleteId(null);
setModalOpen(false);
resetForm();
}
}} }}
/> />
</> </>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -12,13 +12,6 @@ type CreateEntryInput = {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
bucketId?: number | null; bucketId?: number | null;
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
}; };
type UpdateEntryInput = CreateEntryInput & { id: number }; type UpdateEntryInput = CreateEntryInput & { id: number };
@ -63,7 +56,7 @@ export default function useEntries(activeGroupId?: number | null) {
setLoading(false); setLoading(false);
}, [activeGroupId]); }, [activeGroupId]);
const createEntry = useCallback(async (input: CreateEntryInput) => { const createEntry = useCallback(async (input: CreateEntryInput): Promise<Entry | null> => {
setError(""); setError("");
const result = await entriesCreate(input); const result = await entriesCreate(input);
if (isError(result)) { if (isError(result)) {
@ -75,7 +68,7 @@ export default function useEntries(activeGroupId?: number | null) {
return created; return created;
}, []); }, []);
const updateEntry = useCallback(async (input: UpdateEntryInput) => { const updateEntry = useCallback(async (input: UpdateEntryInput): Promise<Entry | null> => {
setError(""); setError("");
const result = await entriesUpdate(input); const result = await entriesUpdate(input);
if (isError(result)) { if (isError(result)) {
@ -89,7 +82,7 @@ export default function useEntries(activeGroupId?: number | null) {
return updated; return updated;
}, []); }, []);
const deleteEntry = useCallback(async (id: number | string) => { const deleteEntry = useCallback(async (id: number | string): Promise<Entry | null> => {
setError(""); setError("");
const numericId = Number(id); const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) return null; if (!Number.isFinite(numericId) || numericId <= 0) return null;

View File

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

View File

@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { userSettingsGet, userSettingsUpdate, type UserSettings } from "@/lib/client/user-settings";
import type { ApiResult } from "@/lib/client/fetch-json";
function isError<T>(result: ApiResult<T>): result is { error: { code: string; message: string } } {
return "error" in result;
}
const defaultSettings: UserSettings = { entryPanelPageSize: 10 };
export default function useUserSettings() {
const [settings, setSettings] = useState<UserSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
const result = await userSettingsGet();
if (isError(result)) setError(result.error.message || "");
else setSettings(result.data.settings || defaultSettings);
setLoading(false);
}, []);
const updateSettings = useCallback(async (next: UserSettings) => {
setError("");
const result = await userSettingsUpdate(next);
if (isError(result)) {
setError(result.error.message || "");
return false;
}
setSettings(result.data.settings);
return true;
}, []);
useEffect(() => {
load();
}, [load]);
return { settings, loading, error, updateSettings, reload: load };
}

View File

@ -14,13 +14,6 @@ export async function entriesCreate(input: {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
bucketId?: number | null; bucketId?: number | null;
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
}) { }) {
return fetchJson<{ entry: Entry }>("/api/entries", { return fetchJson<{ entry: Entry }>("/api/entries", {
method: "POST", method: "POST",
@ -38,13 +31,6 @@ export async function entriesUpdate(input: {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
bucketId?: number | null; bucketId?: number | null;
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
}) { }) {
return fetchJson<{ entry: Entry }>(`/api/entries/${input.id}`, { return fetchJson<{ entry: Entry }>(`/api/entries/${input.id}`, {
method: "PATCH", method: "PATCH",
@ -56,14 +42,7 @@ export async function entriesUpdate(input: {
purchaseType: input.purchaseType, purchaseType: input.purchaseType,
notes: input.notes, notes: input.notes,
tags: input.tags, tags: input.tags,
bucketId: input.bucketId, bucketId: input.bucketId
isRecurring: input.isRecurring,
frequency: input.frequency,
intervalCount: input.intervalCount,
endCondition: input.endCondition,
endCount: input.endCount,
endDate: input.endDate,
nextRunAt: input.nextRunAt
}) })
}); });
} }

View File

@ -1,8 +1,28 @@
import { fetchJson } from "@/lib/client/fetch-json"; import { fetchJson } from "@/lib/client/fetch-json";
import type { Entry } from "@/lib/shared/types";
export type RecurringEntryCompat = {
id: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes: string | null;
receiptId: number | null;
bucketId: number | null;
tags: string[];
isRecurring: true;
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount: number | null;
endDate: string | null;
nextRunAt: string | null;
lastExecutedAt: string | null;
};
export async function recurringEntriesList() { export async function recurringEntriesList() {
return fetchJson<{ entries: Entry[] }>("/api/recurring-entries", { method: "GET" }); return fetchJson<{ entries: RecurringEntryCompat[] }>("/api/recurring-entries", { method: "GET" });
} }
export async function recurringEntriesCreate(input: { export async function recurringEntriesCreate(input: {
@ -14,14 +34,14 @@ export async function recurringEntriesCreate(input: {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
bucketId?: number | null; bucketId?: number | null;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null; frequency?: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null;
intervalCount?: number; intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null; endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null; endCount?: number | null;
endDate?: string | null; endDate?: string | null;
nextRunAt?: string | null; nextRunAt?: string | null;
}) { }) {
return fetchJson<{ entry: Entry }>("/api/recurring-entries", { return fetchJson<{ entry: RecurringEntryCompat }>("/api/recurring-entries", {
method: "POST", method: "POST",
body: JSON.stringify(input) body: JSON.stringify(input)
}); });
@ -37,14 +57,14 @@ export async function recurringEntriesUpdate(input: {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
bucketId?: number | null; bucketId?: number | null;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null; frequency?: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" | null;
intervalCount?: number; intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null; endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null; endCount?: number | null;
endDate?: string | null; endDate?: string | null;
nextRunAt?: string | null; nextRunAt?: string | null;
}) { }) {
return fetchJson<{ entry: Entry }>(`/api/recurring-entries/${input.id}`, { return fetchJson<{ entry: RecurringEntryCompat }>(`/api/recurring-entries/${input.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
entryType: input.entryType, entryType: input.entryType,

View File

@ -0,0 +1,42 @@
import { fetchJson } from "@/lib/client/fetch-json";
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
type ScheduleInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: ScheduleFrequency;
intervalCount?: number;
endCondition?: ScheduleEndCondition;
endCount?: number | null;
endDate?: string | null;
};
export async function schedulesList() {
return fetchJson<{ schedules: Schedule[] }>("/api/schedules", { method: "GET" });
}
export async function schedulesCreate(input: ScheduleInput & { createEntryNow?: boolean }) {
return fetchJson<{ schedule: Schedule }>("/api/schedules", {
method: "POST",
body: JSON.stringify(input)
});
}
export async function schedulesUpdate(input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }) {
return fetchJson<{ schedule: Schedule }>(`/api/schedules/${input.id}`, {
method: "PATCH",
body: JSON.stringify(input)
});
}
export async function schedulesDelete(input: { id: number | string }) {
const numericId = Number(input.id);
if (!Number.isFinite(numericId) || numericId <= 0)
return { error: { code: "INVALID_ID", message: "Invalid id" } } as const;
return fetchJson<{ ok: true }>(`/api/schedules/${numericId}`, { method: "DELETE" });
}

View File

@ -0,0 +1,16 @@
import { fetchJson } from "@/lib/client/fetch-json";
export type UserSettings = {
entryPanelPageSize: number;
};
export async function userSettingsGet() {
return fetchJson<{ settings: UserSettings }>("/api/user/settings", { method: "GET" });
}
export async function userSettingsUpdate(input: UserSettings) {
return fetchJson<{ settings: UserSettings }>("/api/user/settings", {
method: "POST",
body: JSON.stringify(input)
});
}

View File

@ -26,7 +26,6 @@ type UsageEntryRow = {
amount_dollars: string | number; amount_dollars: string | number;
occurred_at: string; occurred_at: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
is_recurring: boolean;
tags: string[] | null; tags: string[] | null;
}; };
@ -47,20 +46,19 @@ export type Bucket = {
async function listUsageEntries(groupId: number) { async function listUsageEntries(groupId: number) {
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<UsageEntryRow>( const rows = (await pool.query<UsageEntryRow>(
`select e.id, e.amount_dollars, e.occurred_at, e.necessity, e.is_recurring, `select e.id, e.amount_dollars, e.occurred_at, e.necessity,
array_remove(array_agg(distinct t.name), null) as tags array_remove(array_agg(distinct t.name), null) as tags
from entries e from entries e
left join entry_tags et on et.entry_id = e.id left join entry_tags et on et.entry_id = e.id
left join tags t on t.id = et.tag_id and t.group_id = $1 left join tags t on t.id = et.tag_id and t.group_id = $1
where e.group_id = $1 and e.entry_type = 'SPENDING' where e.group_id = $1 and e.entry_type = 'SPENDING'
group by e.id, e.amount_dollars, e.occurred_at, e.necessity, e.is_recurring`, group by e.id, e.amount_dollars, e.occurred_at, e.necessity`,
[groupId] [groupId]
)).rows; )).rows;
return rows.map(row => ({ return rows.map(row => ({
amountDollars: Number(row.amount_dollars), amountDollars: Number(row.amount_dollars),
occurredAt: row.occurred_at, occurredAt: row.occurred_at,
necessity: row.necessity, necessity: row.necessity,
isRecurring: row.is_recurring,
tags: row.tags || [], tags: row.tags || [],
entryType: "SPENDING" as const entryType: "SPENDING" as const
})); }));

View File

@ -16,14 +16,7 @@ type EntryRow = {
purchase_type: string; purchase_type: string;
notes: string | null; notes: string | null;
receipt_id: number | null; receipt_id: number | null;
is_recurring: boolean; source_schedule_id: number | null;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
interval_count: number;
end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
end_count: number | null;
end_date: string | null;
next_run_at: string | null;
last_executed_at: string | null;
}; };
export { requireActiveGroup }; export { requireActiveGroup };
@ -32,7 +25,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<EntryRow>( const rows = (await pool.query<EntryRow>(
`select id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id, `select id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at source_schedule_id
from entries from entries
where group_id = $1 where group_id = $1
order by occurred_at desc, id desc`, order by occurred_at desc, id desc`,
@ -51,14 +44,7 @@ export async function listEntries(groupId: number): Promise<Entry[]> {
receiptId: row.receipt_id, receiptId: row.receipt_id,
bucketId: null, bucketId: null,
tags: tagsMap.get(Number(row.id)) || [], tags: tagsMap.get(Number(row.id)) || [],
isRecurring: row.is_recurring, sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
frequency: row.frequency,
intervalCount: Number(row.interval_count || 1),
endCondition: row.end_condition,
endCount: row.end_count,
endDate: row.end_date,
nextRunAt: row.next_run_at,
lastExecutedAt: row.last_executed_at
})); }));
} }
@ -72,23 +58,17 @@ export async function createEntry(input: {
purchaseType: string; purchaseType: string;
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
bucketId?: number | null; bucketId?: number | null;
sourceScheduleId?: number | null;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" }); await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<EntryRow>( const rows = (await pool.query<EntryRow>(
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, `insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes,
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at) source_schedule_id)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) values($1,$2,$3,$4,$5,$6,$7,$8,$9)
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id, returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`, source_schedule_id`,
[ [
input.groupId, input.groupId,
input.userId, input.userId,
@ -98,13 +78,7 @@ export async function createEntry(input: {
input.necessity, input.necessity,
input.purchaseType, input.purchaseType,
input.notes || null, input.notes || null,
Boolean(input.isRecurring), input.sourceScheduleId ?? null
input.frequency || null,
input.intervalCount || 1,
input.endCondition || null,
input.endCount ?? null,
input.endDate || null,
input.nextRunAt || null
] ]
)).rows; )).rows;
const row = rows[0]; const row = rows[0];
@ -123,14 +97,7 @@ export async function createEntry(input: {
receiptId: row.receipt_id, receiptId: row.receipt_id,
bucketId: null, bucketId: null,
tags, tags,
isRecurring: row.is_recurring, sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
frequency: row.frequency,
intervalCount: Number(row.interval_count || 1),
endCondition: row.end_condition,
endCount: row.end_count,
endDate: row.end_date,
nextRunAt: row.next_run_at,
lastExecutedAt: row.last_executed_at
} as Entry; } as Entry;
} }
@ -145,24 +112,16 @@ export async function updateEntry(input: {
purchaseType: string; purchaseType: string;
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
isRecurring?: boolean;
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
bucketId?: number | null; bucketId?: number | null;
}) { }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" }); await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" });
const pool = getPool(); const pool = getPool();
const rows = (await pool.query<EntryRow>( const rows = (await pool.query<EntryRow>(
`update entries `update entries
set entry_type=$1, amount_dollars=$2, occurred_at=$3, necessity=$4, purchase_type=$5, notes=$6, set entry_type=$1, amount_dollars=$2, occurred_at=$3, necessity=$4, purchase_type=$5, notes=$6
is_recurring=$7, frequency=$8, interval_count=$9, end_condition=$10, end_count=$11, end_date=$12, next_run_at=$13 where id=$7 and group_id=$8
where id=$14 and group_id=$15
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id, returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`, source_schedule_id`,
[ [
input.entryType, input.entryType,
input.amountDollars, input.amountDollars,
@ -170,13 +129,6 @@ export async function updateEntry(input: {
input.necessity, input.necessity,
input.purchaseType, input.purchaseType,
input.notes || null, input.notes || null,
Boolean(input.isRecurring),
input.frequency || null,
input.intervalCount || 1,
input.endCondition || null,
input.endCount ?? null,
input.endDate || null,
input.nextRunAt || null,
input.id, input.id,
input.groupId input.groupId
] ]
@ -197,14 +149,7 @@ export async function updateEntry(input: {
receiptId: rows[0].receipt_id, receiptId: rows[0].receipt_id,
bucketId: null, bucketId: null,
tags, tags,
isRecurring: rows[0].is_recurring, sourceScheduleId: rows[0].source_schedule_id == null ? null : Number(rows[0].source_schedule_id)
frequency: rows[0].frequency,
intervalCount: Number(rows[0].interval_count || 1),
endCondition: rows[0].end_condition,
endCount: rows[0].end_count,
endDate: rows[0].end_date,
nextRunAt: rows[0].next_run_at,
lastExecutedAt: rows[0].last_executed_at
} as Entry; } as Entry;
} }

View File

@ -1,23 +1,134 @@
if (process.env.NODE_ENV !== "test") if (process.env.NODE_ENV !== "test")
require("server-only"); require("server-only");
import { createEntry, deleteEntry, listEntries, updateEntry, requireActiveGroup } from "@/lib/server/entries"; import { createSchedule, deleteSchedule, listSchedules, requireActiveGroup, updateSchedule } from "@/lib/server/schedules";
import type { Entry } from "@/lib/shared/types"; import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export { requireActiveGroup }; export { requireActiveGroup };
export async function listRecurringEntries(groupId: number): Promise<Entry[]> { type RecurringEntryCompat = {
const entries = await listEntries(groupId); id: number;
return entries.filter(entry => entry.isRecurring); entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes: string | null;
receiptId: number | null;
bucketId: number | null;
tags: string[];
isRecurring: true;
frequency: ScheduleFrequency;
intervalCount: number;
endCondition: ScheduleEndCondition;
endCount: number | null;
endDate: string | null;
nextRunAt: string | null;
lastExecutedAt: string | null;
};
function toRecurringCompat(schedule: Schedule): RecurringEntryCompat {
return {
id: schedule.id,
entryType: schedule.entryType,
amountDollars: schedule.amountDollars,
occurredAt: schedule.startsOn,
necessity: schedule.necessity,
purchaseType: schedule.purchaseType,
notes: schedule.notes,
receiptId: null,
bucketId: null,
tags: schedule.tags,
isRecurring: true,
frequency: schedule.frequency,
intervalCount: schedule.intervalCount,
endCondition: schedule.endCondition,
endCount: schedule.endCount,
endDate: schedule.endDate,
nextRunAt: schedule.nextRunOn,
lastExecutedAt: schedule.lastRunOn
};
} }
export async function createRecurringEntry(input: Parameters<typeof createEntry>[0]) { export async function listRecurringEntries(groupId: number): Promise<RecurringEntryCompat[]> {
return createEntry({ ...input, isRecurring: true }); const schedules = await listSchedules(groupId);
return schedules.map(toRecurringCompat);
} }
export async function updateRecurringEntry(input: Parameters<typeof updateEntry>[0]) { export async function createRecurringEntry(input: {
return updateEntry({ ...input, isRecurring: true }); groupId: number;
userId: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
frequency?: ScheduleFrequency | null;
intervalCount?: number;
endCondition?: ScheduleEndCondition | null;
endCount?: number | null;
endDate?: string | null;
}) {
const schedule = await createSchedule({
groupId: input.groupId,
userId: input.userId,
entryType: input.entryType,
amountDollars: input.amountDollars,
necessity: input.necessity,
purchaseType: input.purchaseType,
notes: input.notes,
tags: input.tags,
startsOn: input.occurredAt,
frequency: input.frequency || "MONTHLY",
intervalCount: input.intervalCount || 1,
endCondition: input.endCondition || "NEVER",
endCount: input.endCount ?? null,
endDate: input.endDate || null,
createEntryNow: true
});
return toRecurringCompat(schedule);
}
export async function updateRecurringEntry(input: {
id: number;
groupId: number;
userId: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
frequency?: ScheduleFrequency | null;
intervalCount?: number;
endCondition?: ScheduleEndCondition | null;
endCount?: number | null;
endDate?: string | null;
nextRunAt?: string | null;
}) {
const schedule = await updateSchedule({
id: input.id,
groupId: input.groupId,
userId: input.userId,
entryType: input.entryType,
amountDollars: input.amountDollars,
necessity: input.necessity,
purchaseType: input.purchaseType,
notes: input.notes,
tags: input.tags,
startsOn: input.occurredAt,
frequency: input.frequency || "MONTHLY",
intervalCount: input.intervalCount || 1,
endCondition: input.endCondition || "NEVER",
endCount: input.endCount ?? null,
endDate: input.endDate || null,
nextRunOn: input.nextRunAt || input.occurredAt
});
return schedule ? toRecurringCompat(schedule) : null;
} }
export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) { export async function deleteRecurringEntry(input: { id: number; groupId: number; userId: number }) {
return deleteEntry(input); return deleteSchedule(input);
} }

View File

@ -0,0 +1,233 @@
if (process.env.NODE_ENV !== "test")
require("server-only");
import getPool from "@/lib/server/db";
import { createEntry, requireActiveGroup } from "@/lib/server/entries";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
import { listTagsForSchedules, normalizeTags, requireExistingTagsForGroup, setScheduleTags } from "@/lib/server/tags";
import type { Schedule, ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
type ScheduleRow = {
id: number;
entry_type: "SPENDING" | "INCOME";
amount_dollars: string | number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchase_type: string;
notes: string | null;
starts_on: string;
frequency: ScheduleFrequency;
interval_count: number;
end_condition: ScheduleEndCondition;
end_count: number | null;
end_date: string | null;
next_run_on: string;
last_run_on: string | null;
run_count: number;
is_active: boolean;
legacy_source_entry_id: number | null;
};
type CreateScheduleInput = {
groupId: number;
userId: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: ScheduleFrequency;
intervalCount?: number;
endCondition?: ScheduleEndCondition;
endCount?: number | null;
endDate?: string | null;
createEntryNow?: boolean;
};
type UpdateScheduleInput = {
id: number;
groupId: number;
userId: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: ScheduleFrequency;
intervalCount?: number;
endCondition?: ScheduleEndCondition;
endCount?: number | null;
endDate?: string | null;
nextRunOn?: string;
isActive?: boolean;
};
export { requireActiveGroup };
function addInterval(dateIso: string, frequency: ScheduleFrequency, intervalCount: number) {
const safeInterval = Math.max(1, Number(intervalCount || 1));
const date = new Date(`${dateIso}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return dateIso;
if (frequency === "DAILY") date.setUTCDate(date.getUTCDate() + safeInterval);
else if (frequency === "WEEKLY") date.setUTCDate(date.getUTCDate() + safeInterval * 7);
else if (frequency === "MONTHLY") date.setUTCMonth(date.getUTCMonth() + safeInterval);
else date.setUTCFullYear(date.getUTCFullYear() + safeInterval);
return date.toISOString().slice(0, 10);
}
function toSchedule(row: ScheduleRow, tags: string[]): Schedule {
return {
id: Number(row.id),
entryType: row.entry_type,
amountDollars: Number(row.amount_dollars),
necessity: row.necessity,
purchaseType: row.purchase_type,
notes: row.notes,
startsOn: row.starts_on,
frequency: row.frequency,
intervalCount: Number(row.interval_count || 1),
endCondition: row.end_condition,
endCount: row.end_count,
endDate: row.end_date,
nextRunOn: row.next_run_on,
lastRunOn: row.last_run_on,
runCount: Number(row.run_count || 0),
isActive: Boolean(row.is_active),
legacySourceEntryId: row.legacy_source_entry_id == null ? null : Number(row.legacy_source_entry_id),
tags
};
}
export async function listSchedules(groupId: number): Promise<Schedule[]> {
const pool = getPool();
const rows = (await pool.query<ScheduleRow>(
`select id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id
from schedules
where group_id=$1
order by next_run_on asc, id asc`,
[groupId]
)).rows;
const scheduleIds = rows.map(row => Number(row.id));
const tagsMap = await listTagsForSchedules({ groupId, scheduleIds });
return rows.map(row => toSchedule(row, tagsMap.get(Number(row.id)) || []));
}
export async function createSchedule(input: CreateScheduleInput): Promise<Schedule> {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:create" });
const pool = getPool();
const safeIntervalCount = Math.max(1, Number(input.intervalCount || 1));
const endCondition = input.endCondition || "NEVER";
const rows = (await pool.query<ScheduleRow>(
`insert into schedules(group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on,
frequency, interval_count, end_condition, end_count, end_date, next_run_on, is_active)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,true)
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
[
input.groupId,
input.userId,
input.entryType,
input.amountDollars,
input.necessity,
input.purchaseType,
input.notes || null,
input.startsOn,
input.frequency,
safeIntervalCount,
endCondition,
input.endCount ?? null,
input.endDate || null,
input.startsOn
]
)).rows;
const created = rows[0];
const tags = normalizeTags(input.tags || []);
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
await setScheduleTags({ scheduleId: Number(created.id), tagIds });
if (input.createEntryNow) {
const nextRunOn = addInterval(input.startsOn, input.frequency, safeIntervalCount);
await createEntry({
groupId: input.groupId,
userId: input.userId,
entryType: input.entryType,
amountDollars: input.amountDollars,
occurredAt: input.startsOn,
necessity: input.necessity,
purchaseType: input.purchaseType,
notes: input.notes,
tags,
sourceScheduleId: Number(created.id)
});
const updatedRows = (await pool.query<ScheduleRow>(
`update schedules
set next_run_on=$1, last_run_on=$2, run_count=1, updated_at=now()
where id=$3 and group_id=$4
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
[nextRunOn, input.startsOn, Number(created.id), input.groupId]
)).rows;
return toSchedule(updatedRows[0], tags);
}
return toSchedule(created, tags);
}
export async function updateSchedule(input: UpdateScheduleInput): Promise<Schedule | null> {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:update" });
const pool = getPool();
const safeIntervalCount = Math.max(1, Number(input.intervalCount || 1));
const endCondition = input.endCondition || "NEVER";
const nextRunOn = input.nextRunOn || input.startsOn;
const rows = (await pool.query<ScheduleRow>(
`update schedules
set entry_type=$1,
amount_dollars=$2,
necessity=$3,
purchase_type=$4,
notes=$5,
starts_on=$6,
frequency=$7,
interval_count=$8,
end_condition=$9,
end_count=$10,
end_date=$11,
next_run_on=$12,
is_active=$13,
updated_at=now()
where id=$14 and group_id=$15
returning id, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count,
end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id`,
[
input.entryType,
input.amountDollars,
input.necessity,
input.purchaseType,
input.notes || null,
input.startsOn,
input.frequency,
safeIntervalCount,
endCondition,
input.endCount ?? null,
input.endDate || null,
nextRunOn,
input.isActive ?? true,
input.id,
input.groupId
]
)).rows;
if (!rows[0]) return null;
const tags = normalizeTags(input.tags || []);
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
await setScheduleTags({ scheduleId: Number(input.id), tagIds });
return toSchedule(rows[0], tags);
}
export async function deleteSchedule(input: { id: number; groupId: number; userId: number }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "schedules:delete" });
const pool = getPool();
await pool.query("delete from schedules where id=$1 and group_id=$2", [input.id, input.groupId]);
}

View File

@ -9,6 +9,7 @@ type TagRow = { id: number; name: string };
type TagListRow = { name: string }; type TagListRow = { name: string };
type EntryTagsRow = { entry_id: number; tags: string[] }; type EntryTagsRow = { entry_id: number; tags: string[] };
type BucketTagsRow = { bucket_id: number; tags: string[] }; type BucketTagsRow = { bucket_id: number; tags: string[] };
type ScheduleTagsRow = { schedule_id: number; tags: string[] };
function normalizeTag(tag: string) { function normalizeTag(tag: string) {
return tag return tag
@ -128,6 +129,32 @@ export async function listTagsForEntries(input: { groupId: number; entryIds: num
return map; return map;
} }
export async function setScheduleTags(input: { scheduleId: number; tagIds: number[] }) {
const pool = getPool();
await pool.query("delete from schedule_tags where schedule_id=$1", [input.scheduleId]);
if (!input.tagIds.length) return;
const values = input.tagIds.map((_, idx) => `($1,$${idx + 2})`).join(",");
const params = [input.scheduleId, ...input.tagIds];
await pool.query(`insert into schedule_tags(schedule_id, tag_id) values ${values}`, params);
}
export async function listTagsForSchedules(input: { groupId: number; scheduleIds: number[] }) {
if (!input.scheduleIds.length) return new Map<number, string[]>();
const pool = getPool();
const rows = (await pool.query<ScheduleTagsRow>(
`select st.schedule_id, array_agg(t.name order by t.name asc) as tags
from schedule_tags st
join tags t on t.id = st.tag_id
where t.group_id=$1 and st.schedule_id = any($2::bigint[])
group by st.schedule_id`,
[input.groupId, input.scheduleIds]
)).rows;
const map = new Map<number, string[]>();
rows.forEach((row: ScheduleTagsRow) => map.set(Number(row.schedule_id), (row.tags || []).map((tag: string) => String(tag))));
return map;
}
export async function setBucketTags(input: { bucketId: number; tagIds: number[] }) { export async function setBucketTags(input: { bucketId: number; tagIds: number[] }) {
const pool = getPool(); const pool = getPool();
await pool.query("delete from bucket_tags where bucket_id=$1", [input.bucketId]); await pool.query("delete from bucket_tags where bucket_id=$1", [input.bucketId]);

View File

@ -0,0 +1,50 @@
if (process.env.NODE_ENV !== "test")
require("server-only");
import getPool from "@/lib/server/db";
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
const DEFAULT_ENTRY_PANEL_PAGE_SIZE = 10;
const MIN_ENTRY_PANEL_PAGE_SIZE = 1;
const MAX_ENTRY_PANEL_PAGE_SIZE = 100;
export type UserSettings = {
entryPanelPageSize: number;
};
function normalizeEntryPanelPageSize(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric))
return DEFAULT_ENTRY_PANEL_PAGE_SIZE;
return Math.min(MAX_ENTRY_PANEL_PAGE_SIZE, Math.max(MIN_ENTRY_PANEL_PAGE_SIZE, Math.floor(numeric)));
}
export function getDefaultUserSettings(): UserSettings {
return { entryPanelPageSize: DEFAULT_ENTRY_PANEL_PAGE_SIZE };
}
export async function getUserSettings(userId: number): Promise<UserSettings> {
const pool = getPool();
const { rows } = await pool.query<{ data: Record<string, unknown> }>(
"select data from user_settings where user_id=$1",
[userId]
);
const data = rows[0]?.data || {};
return {
entryPanelPageSize: normalizeEntryPanelPageSize(data.entryPanelPageSize)
};
}
export async function setUserSettings(input: { userId: number; entryPanelPageSize: number }) {
await enforceUserWriteRateLimit({ userId: input.userId, scope: "user-settings:update" });
const pool = getPool();
const entryPanelPageSize = normalizeEntryPanelPageSize(input.entryPanelPageSize);
await pool.query(
`insert into user_settings(user_id, data)
values($1, jsonb_build_object('entryPanelPageSize', $2::int))
on conflict (user_id) do update
set data = user_settings.data || jsonb_build_object('entryPanelPageSize', $2::int),
updated_at = now()`,
[input.userId, entryPanelPageSize]
);
return { entryPanelPageSize };
}

View File

@ -9,7 +9,6 @@ export type BucketUsageEntry = {
occurredAt: string | Date; occurredAt: string | Date;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
tags: string[]; tags: string[];
isRecurring: boolean;
entryType?: "SPENDING" | "INCOME"; entryType?: "SPENDING" | "INCOME";
}; };
@ -50,7 +49,6 @@ export function calculateBucketUsage(bucket: BucketUsageBucket, entries: BucketU
entries.forEach(entry => { entries.forEach(entry => {
if (entry.entryType && entry.entryType !== "SPENDING") return; if (entry.entryType && entry.entryType !== "SPENDING") return;
if (entry.isRecurring) return;
if (!hasAllTags(bucket.tags || [], entry.tags || [])) return; if (!hasAllTags(bucket.tags || [], entry.tags || [])) return;
const occurred = toDateOnly(entry.occurredAt); const occurred = toDateOnly(entry.occurredAt);

View File

@ -21,12 +21,29 @@ export type Entry = {
receiptId: number | null; receiptId: number | null;
bucketId: number | null; bucketId: number | null;
tags: string[]; tags: string[];
isRecurring: boolean; sourceScheduleId: number | null;
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null; };
export type ScheduleFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type ScheduleEndCondition = "NEVER" | "AFTER_COUNT" | "BY_DATE";
export type Schedule = {
id: number;
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes: string | null;
startsOn: string;
frequency: ScheduleFrequency;
intervalCount: number; intervalCount: number;
endCondition: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null; endCondition: ScheduleEndCondition;
endCount: number | null; endCount: number | null;
endDate: string | null; endDate: string | null;
nextRunAt: string | null; nextRunOn: string;
lastExecutedAt: string | null; lastRunOn: string | null;
runCount: number;
isActive: boolean;
legacySourceEntryId: number | null;
tags: string[];
}; };

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,13 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
COPY apps/scheduler/package*.json apps/scheduler/
COPY apps/web/package*.json apps/web/
COPY packages/db/package*.json packages/db/
COPY packages/shared/package*.json packages/shared/
RUN npm ci
COPY apps/scheduler ./apps/scheduler
CMD ["npm", "run", "start", "--workspace", "apps/scheduler"]

View File

@ -0,0 +1,23 @@
# Place in NPM global custom config loaded in `http` context
# (commonly `/data/nginx/custom/http_top.conf`).
limit_req_zone $binary_remote_addr zone=fiddy_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=fiddy_write:10m rate=60r/m;
limit_conn_zone $binary_remote_addr zone=fiddy_conn:10m;
log_format fiddy_json escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request_id":"$request_id",'
'"request_method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';

View File

@ -0,0 +1,12 @@
# Paste into NPM Custom Location advanced config for:
# - /api/auth/login
# - /api/auth/register
#
# Requires `limit_req_zone fiddy_auth` in http context.
# Keep request-id forwarding/header here too because this location is
# more specific than `/` and can bypass root location directives.
add_header X-Request-Id $request_id always;
proxy_set_header X-Request-Id $request_id;
limit_req zone=fiddy_auth burst=15 nodelay;

View File

@ -0,0 +1,15 @@
# Paste into NPM Custom Location advanced config for:
# - /
#
# This is where NPM reliably applies add_header/proxy_set_header directives.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
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;
proxy_set_header X-Request-Id $request_id;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 5s;

View File

@ -0,0 +1,18 @@
# Paste into NPM Custom Location advanced config for write-heavy API paths,
# for example:
# - /api/entries
# - /api/buckets
# - /api/groups
# - /api/tags
# - /api/recurring-entries
#
# Requires `limit_req_zone fiddy_write` in http context.
# Keep request-id forwarding/header here too because this location is
# more specific than `/` and can bypass root location directives.
add_header X-Request-Id $request_id always;
proxy_set_header X-Request-Id $request_id;
if ($request_method ~* "(POST|PATCH|PUT|DELETE)") {
limit_req zone=fiddy_write burst=40 nodelay;
}

View File

@ -0,0 +1,21 @@
# Paste into Nginx Proxy Manager -> Proxy Host -> Advanced
# (server block context for this host).
#
# IMPORTANT:
# In NPM, `add_header` and `proxy_set_header` do not reliably apply from this section.
# Put those directives in Custom Location advanced config (especially location `/`).
server_tokens off;
client_max_body_size 10m;
client_body_timeout 15s;
client_header_timeout 15s;
keepalive_timeout 30s;
send_timeout 30s;
# Requires `limit_conn_zone` defined globally in http context.
limit_conn fiddy_conn 50;
# Prefer dedicated per-host log path if available on your NPM host.
access_log /var/log/nginx/fiddy-access.log fiddy_json;
error_log /var/log/nginx/fiddy-error.log warn;

View File

@ -133,6 +133,21 @@ Primary outcomes:
- `scripts/log-restore-drill.sh` to append timestamped restore outcomes and measured RTO. - `scripts/log-restore-drill.sh` to append timestamped restore outcomes and measured RTO.
- Added consolidated execution checklist: - Added consolidated execution checklist:
- `docs/07_PUBLIC_LAUNCH_CHECKLIST.md` for go-live gating across infra, deploy, security, observability, DR, and rollback. - `docs/07_PUBLIC_LAUNCH_CHECKLIST.md` for go-live gating across infra, deploy, security, observability, DR, and rollback.
- Clarified deployment assumption:
- use existing external Nginx edge; `docker/nginx/*` is now documented as template/reference config, not a required new Nginx runtime.
- Added Nginx Proxy Manager setup pack:
- `docs/08_NGINX_PROXY_MANAGER_SETUP.md` (UI + SSH fallback instructions).
- `docker/nginx/npm/*.example` snippets for host advanced config, location limits, and http-level zones.
- Adjusted NPM guidance based on real NPM behavior:
- moved header directives to custom location snippets (especially `/`) because Proxy Host Advanced does not reliably apply `add_header`/`proxy_set_header`.
- Added staged operator playbook:
- `docs/09_DEPLOYMENT_EXECUTION_PLAYBOOK.md` to separate no-touch prep from hands-on infra steps.
- Deferred (tracked) for later session:
- host-specific NPM tailoring for exact domain/upstream/custom-location layout.
- Added NPM execution runsheet:
- `docs/10_NPM_HANDS_ON_RUNSHEET.md` with strict run order and verification checkpoints.
- Added first-time Dokploy onboarding guide:
- `docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md` with install, app wiring, webhook, and rollback test flow.
### Risks / Notes to Revisit ### Risks / Notes to Revisit
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes. - Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.

View File

@ -8,9 +8,9 @@ This document tracks launch-critical security findings for app, data, users, and
1. Direct home-IP exposure increases scanning and DDoS risk. 1. Direct home-IP exposure increases scanning and DDoS risk.
- Status: Partially mitigated. - Status: Partially mitigated.
- Mitigations in repo: - Mitigations in repo:
- TLS + HTTPS redirect (`docker/nginx/fiddy.conf`) - TLS + HTTPS redirect (apply via existing Nginx using `docker/nginx/fiddy.conf` template)
- request rate limits (`docker/nginx/fiddy.conf`) - request rate limits (existing Nginx with `docker/nginx/fiddy.conf` template)
- connection cap (`docker/nginx/fiddy.conf`) - connection cap (existing Nginx with `docker/nginx/fiddy.conf` template)
- Required ops actions: - Required ops actions:
- enforce host firewall allowlist rules - enforce host firewall allowlist rules
- restrict SSH to VPN or fixed allowlist - restrict SSH to VPN or fixed allowlist
@ -68,7 +68,6 @@ This document tracks launch-critical security findings for app, data, users, and
- [x] `npm run build` passes. - [x] `npm run build` passes.
- [ ] Production host firewall rules verified (`scripts/harden-host-ufw.sh` + `scripts/check-host-security.sh`). - [ ] Production host firewall rules verified (`scripts/harden-host-ufw.sh` + `scripts/check-host-security.sh`).
- [ ] SSH restricted to VPN/allowlist. - [ ] SSH restricted to VPN/allowlist.
- [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh`).
- [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh` + `scripts/log-restore-drill.sh`). - [ ] Backup restore drill logged for current week (`scripts/restore-drill-postgres.sh` + `scripts/log-restore-drill.sh`).
- [ ] Base backup job configured and validated (`scripts/basebackup-postgres.sh`). - [ ] Base backup job configured and validated (`scripts/basebackup-postgres.sh`).
- [ ] Auto-ban tooling configured (fail2ban or crowdsec) from `docker/security/`. - [ ] Auto-ban tooling configured (fail2ban or crowdsec) from `docker/security/`.

View File

@ -17,13 +17,17 @@
- [ ] `SESSION_TTL_DAYS` - [ ] `SESSION_TTL_DAYS`
- [ ] `DEBUG_API=0` - [ ] `DEBUG_API=0`
- [ ] `DOKPLOY_DEPLOY_HOOK` - [ ] `DOKPLOY_DEPLOY_HOOK`
- [ ] `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
- [ ] `DOKPLOY_HEALTHCHECK_URL` - [ ] `DOKPLOY_HEALTHCHECK_URL`
- [ ] Deploy workflow passes build/test/push/deploy. - [ ] Deploy workflow passes build/test/push/deploy.
- [ ] Scheduler deploy workflow step passes.
- [ ] Post-deploy health gate passes (`scripts/wait-for-health.sh`). - [ ] Post-deploy health gate passes (`scripts/wait-for-health.sh`).
- [ ] Manual smoke passes (`scripts/smoke-public-launch.sh`). - [ ] Manual smoke passes (`scripts/smoke-public-launch.sh`).
## C) Security Controls ## C) Security Controls
- [ ] Nginx TLS/headers/rate limits enabled (`docker/nginx/fiddy.conf`). - [ ] Existing Nginx TLS/headers/rate limits enabled (using `docker/nginx/fiddy.conf` template).
- [ ] If using NPM, `docs/08_NGINX_PROXY_MANAGER_SETUP.md` completed.
- [ ] If using NPM, Custom Location `/` includes header/request-id snippet.
- [ ] Request-id propagation enabled (`X-Request-Id` in responses). - [ ] Request-id propagation enabled (`X-Request-Id` in responses).
- [ ] Server-side rate limits active (auth/write/ip limiters). - [ ] Server-side rate limits active (auth/write/ip limiters).
- [ ] Fail2ban or CrowdSec configured from `docker/security/`. - [ ] Fail2ban or CrowdSec configured from `docker/security/`.

View File

@ -0,0 +1,115 @@
# Nginx Proxy Manager Setup (Existing Edge)
This guide assumes you already run Nginx Proxy Manager (NPM) as your shared reverse proxy and want to route Fiddy through it.
## 1) Proxy Host in NPM UI
1. Create a Proxy Host for your Fiddy domain.
2. Forward Hostname/IP: your app host/internal IP.
3. Forward Port: your app port (for example `3000`).
4. Enable:
- Block Common Exploits
- Websockets Support
- SSL certificate
- Force SSL
- HTTP/2 support
## 2) Host Advanced Config (NPM UI)
In Proxy Host -> Advanced, paste from:
- `docker/nginx/npm/proxy-host-advanced.conf.example`
This adds:
- timeout/body limits
- connection cap
- structured access/error logs
## 3) Required Root Custom Location `/` (NPM UI)
Create a Custom Location for:
- `/`
In that location Advanced field, paste:
- `docker/nginx/npm/location-root-advanced.conf.example`
This handles:
- security headers
- request-id propagation/response header
- upstream proxy timeouts
## 4) Per-Location Rate Limits (NPM UI)
Create Custom Locations in NPM for:
- `/api/auth/login`
- `/api/auth/register`
- `/api/entries`
- `/api/buckets`
- `/api/groups`
- `/api/tags`
- `/api/schedules` (canonical)
- `/api/recurring-entries` (compatibility, deprecated)
Then use:
- `docker/nginx/npm/location-auth-advanced.conf.example` for auth locations
- `docker/nginx/npm/location-write-advanced.conf.example` for write API locations
Note:
- because these are more specific locations than `/`, keep request-id directives in these location snippets too.
## 5) Global NPM Config Needed for Rate Limit Zones
`limit_req_zone`, `limit_conn_zone`, and `log_format` must exist in Nginx `http` context.
Use template:
- `docker/nginx/npm/http_top.conf.example`
Typical NPM path:
- `/data/nginx/custom/http_top.conf`
## 6) SSH Method (If UI Is Not Enough)
If your NPM UI does not expose everything you need:
1. Enter the container:
```bash
docker exec -it <npm_container_name> sh
```
2. Verify active config and custom includes:
```bash
nginx -T | grep -n "include .*custom"
nginx -T | grep -n "http_top.conf"
```
3. Write global HTTP custom file (path may vary by image/version):
```bash
mkdir -p /data/nginx/custom
cat >/data/nginx/custom/http_top.conf <<'EOF'
# paste docker/nginx/npm/http_top.conf.example content
EOF
```
4. Reload Nginx:
```bash
nginx -t
nginx -s reload
```
5. In NPM UI, apply:
- host advanced snippet
- location `/` snippet
- auth/write location snippets
## 7) Log Path Alignment
If your NPM uses a different log path than `/var/log/nginx`:
- update `access_log` / `error_log` lines in your host advanced config
- update:
- `docker/observability/promtail-config.yml`
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
- `docker/security/crowdsec/acquis.yaml`
## 8) Validate
Run:
```bash
scripts/smoke-public-launch.sh https://your-domain
```
Then confirm:
- `X-Request-Id` response header exists
- response JSON includes `request_id`
- nginx access logs receive entries for the Fiddy host
- auth and write endpoint bursts are rate limited

View File

@ -0,0 +1,126 @@
# Deployment Execution Playbook (Hands-On Checkpoints)
Purpose: keep implementation work prepared in-repo, and call for operator actions only when local infrastructure access is required.
## Status Icon Legend
Use these in execution updates for fast scanning:
- `🔄` in progress
- `✅` completed
- `🧪` test/lint/verification result
- `📄` documentation update
- `🗄️` database or migration change
- `🚀` deploy/release step
- `⚠️` risk, blocker, or manual operator action needed
- `❌` failed command or unsuccessful attempt
- `` informational context
- `🧭` recommendation or next-step option
## Phase 0: Preflight (No Infra Changes)
- [ ] `npm run lint`
- [ ] `npm test`
- [ ] `npm run build`
- [ ] Confirm docs are up to date:
- [ ] `docs/public-launch-runbook.md`
- [ ] `docs/07_PUBLIC_LAUNCH_CHECKLIST.md`
- [ ] `docs/08_NGINX_PROXY_MANAGER_SETUP.md`
- [ ] `docs/06_SECURITY_REVIEW.md`
## Phase 1: Registry + Dokploy Wiring (Operator Needed)
First-time reference: `docs/11_DOKPLOY_FIRST_TIME_WALKTHROUGH.md`.
Hands-on checkpoints:
1. Create/verify secrets in Gitea:
- `REGISTRY_USER`
- `REGISTRY_PASS`
- `DOKPLOY_DEPLOY_HOOK`
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
- `DOKPLOY_HEALTHCHECK_URL`
2. In Dokploy app settings (Web):
- image source points to `git.nicosaya.com/nalalangan/fiddy/web`
- health endpoint is `/api/health/ready`
- release history retention is enabled
3. In Dokploy app settings (Scheduler):
- image source points to `git.nicosaya.com/nalalangan/fiddy/scheduler`
- no public port exposed
- env vars set: `DATABASE_URL`, `DATABASE_SSL`, `ALLOWED_DB_NAMES`
Validation:
- [ ] Push-to-main triggers `.gitea/workflows/deploy-dokploy.yml`
- [ ] Web and Scheduler deploy hooks fire successfully
- [ ] Health gate completes via `scripts/wait-for-health.sh`
## Phase 2: NPM Edge Setup (Operator Needed)
Use `docs/08_NGINX_PROXY_MANAGER_SETUP.md`.
Execution order helper: `docs/10_NPM_HANDS_ON_RUNSHEET.md`.
Hands-on checkpoints:
1. Proxy Host for Fiddy domain configured to internal app IP:port.
2. Proxy Host Advanced:
- `docker/nginx/npm/proxy-host-advanced.conf.example`
3. Custom Location `/`:
- `docker/nginx/npm/location-root-advanced.conf.example`
4. Custom auth/write locations:
- `docker/nginx/npm/location-auth-advanced.conf.example`
- `docker/nginx/npm/location-write-advanced.conf.example`
5. Global NPM `http` config includes:
- `docker/nginx/npm/http_top.conf.example`
Validation:
- [ ] `scripts/smoke-public-launch.sh https://<domain>` passes
- [ ] Response header `X-Request-Id` present
- [ ] Response body includes `request_id`
- [ ] Rate limits are active under burst tests
## Phase 3: Host Security Baseline (Operator Needed)
Hands-on checkpoints:
1. Firewall baseline:
- dry run: `SSH_ALLOW_CIDR=<cidr> DRY_RUN=1 scripts/harden-host-ufw.sh`
- apply: `SSH_ALLOW_CIDR=<cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh`
2. Security snapshot:
- `scripts/check-host-security.sh`
3. Auto-ban tooling:
- fail2ban and/or crowdsec using `docker/security/*`
Validation:
- [ ] Only expected public ports exposed (`80/443`)
- [ ] SSH restricted by allowlist/VPN
- [ ] Ban tooling sees nginx logs and can ban test offender
## Phase 4: Observability + Alerts (Operator Needed)
Hands-on checkpoints:
1. Start stack:
- `docker compose -f docker/observability/docker-compose.observability.yml up -d`
2. Grafana datasource:
- Loki `http://loki:3100`
3. Uptime Kuma monitors:
- `/api/health/live`
- `/api/health/ready`
- `/`
Validation:
- [ ] nginx logs appear in Loki (`job="nginx"`)
- [ ] alert rules configured (5xx/auth spikes/DB failures/resource pressure)
## Phase 5: Backup + DR (Operator Needed)
Hands-on checkpoints:
1. Schedule logical backups:
- `scripts/backup-postgres.sh`
2. Schedule periodic base backups:
- `PRIMARY_DATABASE_URL=<replication-url> scripts/basebackup-postgres.sh`
3. Run restore drill:
- `scripts/restore-drill-postgres.sh <dump> <target_db_url>`
4. Log drill:
- `scripts/log-restore-drill.sh <env> <dump> <target> <status> <rto_min> <notes>`
Validation:
- [ ] latest drill entry in `docs/restore-drill-log.csv`
- [ ] measured RTO acceptable
## Phase 6: Launch Gate
Run final checklist:
- `docs/07_PUBLIC_LAUNCH_CHECKLIST.md`
Go-live only after all required boxes are checked.
## Deferred Item (Intentional)
- NPM host-specific tailoring (domain/upstream/custom locations) is intentionally deferred and tracked for a later hands-on session.

View File

@ -0,0 +1,101 @@
# NPM Hands-On Runsheet
Use this when you are ready to actively configure Nginx Proxy Manager for Fiddy.
## Inputs To Decide First
- `DOMAIN`: Fiddy public domain (example: `fiddy.example.com`)
- `UPSTREAM_HOST`: internal app host/IP (example: `192.168.1.50`)
- `UPSTREAM_PORT`: app port (default `3000`)
- `NPM_CONTAINER`: your NPM container name (for SSH fallback)
- `NPM_LOG_PATH`: log path if different from `/var/log/nginx`
## Run 1: Proxy Host Baseline (NPM UI)
1. Proxy Hosts -> Add Proxy Host.
2. Domain Names: `DOMAIN`.
3. Scheme: `http`.
4. Forward Hostname/IP: `UPSTREAM_HOST`.
5. Forward Port: `UPSTREAM_PORT`.
6. Enable:
- Block Common Exploits
- Websockets Support
7. SSL tab:
- Request/choose cert
- Force SSL
- HTTP/2
Stop and verify:
- opening `https://DOMAIN` reaches app homepage.
## Run 2: Proxy Host Advanced (NPM UI)
Paste:
- `docker/nginx/npm/proxy-host-advanced.conf.example`
Stop and verify:
- save succeeds with no Nginx validation errors.
## Run 3: Root Location `/` (NPM UI)
1. In that Proxy Host, add Custom Location path `/`.
2. Paste:
- `docker/nginx/npm/location-root-advanced.conf.example`
Stop and verify:
- `curl -I https://DOMAIN` includes `X-Request-Id`.
## Run 4: API Location Controls (NPM UI)
Add custom locations and advanced snippets:
- `/api/auth/login` -> `docker/nginx/npm/location-auth-advanced.conf.example`
- `/api/auth/register` -> `docker/nginx/npm/location-auth-advanced.conf.example`
- `/api/entries` -> `docker/nginx/npm/location-write-advanced.conf.example`
- `/api/buckets` -> `docker/nginx/npm/location-write-advanced.conf.example`
- `/api/groups` -> `docker/nginx/npm/location-write-advanced.conf.example`
- `/api/tags` -> `docker/nginx/npm/location-write-advanced.conf.example`
- `/api/schedules` (canonical) -> `docker/nginx/npm/location-write-advanced.conf.example`
- `/api/recurring-entries` (compatibility, deprecated) -> `docker/nginx/npm/location-write-advanced.conf.example`
Stop and verify:
- auth/login bad-password bursts eventually return `429`.
## Run 5: Global Nginx `http` Snippet (SSH fallback if needed)
If NPM UI does not expose global `http` context:
1. `docker exec -it NPM_CONTAINER sh`
2. Ensure custom path exists:
```bash
mkdir -p /data/nginx/custom
```
3. Write:
```bash
cat >/data/nginx/custom/http_top.conf <<'EOF'
# paste docker/nginx/npm/http_top.conf.example
EOF
```
4. Reload:
```bash
nginx -t && nginx -s reload
```
Stop and verify:
- no reload errors
- rate limit zones are recognized
## Run 6: Final Functional Validation
Run:
```bash
scripts/smoke-public-launch.sh https://DOMAIN
```
Expected:
- `/api/health/live` and `/api/health/ready` are `200`
- `X-Request-Id` header present
- JSON response contains `request_id`
## Run 7: Log Path Alignment (if needed)
If NPM logs are not in `/var/log/nginx`:
- update:
- `docker/observability/promtail-config.yml`
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
- `docker/security/crowdsec/acquis.yaml`
## Completion Criteria
- All Run 1-6 checks pass.
- NPM config persists across restart.
- Smoke check passes after NPM restart.

View File

@ -0,0 +1,110 @@
# Dokploy First-Time Walkthrough
This is a first-use setup guide for Dokploy in your environment.
## 0) Important Port Constraint
Dokploy installation expects ports `80`, `443`, and `3000` available on the host where Dokploy is installed.
If your Nginx Proxy Manager already uses `80/443` on that same machine, install Dokploy on a separate VM/host (recommended).
## 1) Install Dokploy (Operator Step)
On the target Dokploy server (Linux, root):
```bash
curl -sSL https://dokploy.com/install.sh | sh
```
Then open:
- `http://<dokploy-host>:3000`
Create your admin account.
## 2) Create the Web App in Dokploy (Image-Based)
Create an `Application` service for Fiddy Web and configure:
- Source type: Docker image
- Image: `git.nicosaya.com/nalalangan/fiddy/web:main`
- Internal app port: `3000`
- Health endpoint: `/api/health/ready`
Set environment variables:
- `DATABASE_URL`
- `DATABASE_SSL`
- `ALLOWED_DB_NAMES`
- `SESSION_COOKIE_NAME`
- `SESSION_TTL_DAYS`
- `DEBUG_API=0`
## 3) Create the Scheduler App in Dokploy (Image-Based)
Create a second `Application` service for Scheduler and configure:
- Source type: Docker image
- Image: `git.nicosaya.com/nalalangan/fiddy/scheduler:main`
- No public port required
Set environment variables:
- `DATABASE_URL`
- `DATABASE_SSL`
- `ALLOWED_DB_NAMES`
- `SCHEDULER_POLL_MS` (optional, default `60000`)
- `SCHEDULER_BATCH_SIZE` (optional, default `100`)
## 4) Configure Registry Credentials in Dokploy
Add your registry credentials in Dokploy registry settings for:
- registry host: `git.nicosaya.com`
- username/password (or token)
Use the registry in the application configuration so image pulls succeed.
## 5) Configure Auto Deploy Hooks
In both Dokploy applications (Web + Scheduler), enable Auto Deploy and copy each Webhook URL.
Set this value in Gitea repo secrets as:
- `DOKPLOY_DEPLOY_HOOK`
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
Set health URL secret too:
- `DOKPLOY_HEALTHCHECK_URL=https://<public-domain>/api/health/ready`
## 6) Wire Gitea Secrets
In Gitea repository secrets, add:
- `REGISTRY_USER`
- `REGISTRY_PASS`
- `DOKPLOY_DEPLOY_HOOK`
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
- `DOKPLOY_HEALTHCHECK_URL`
The workflow in `.gitea/workflows/deploy-dokploy.yml` will:
1. build + push web and scheduler images
2. call Dokploy deploy hook(s)
3. wait for web ready health via `scripts/wait-for-health.sh`
## 7) First Deployment Test
1. Push `main`.
2. Confirm Gitea workflow succeeds.
3. In Dokploy, verify both Web and Scheduler show successful pull/start logs.
4. Run smoke check:
```bash
scripts/smoke-public-launch.sh https://<domain>
```
Expected:
- `200` for `/api/health/live` and `/api/health/ready`
- `X-Request-Id` present
- `request_id` present in response JSON
## 8) Rollback Test (Required Before Public Launch)
In Dokploy, trigger rollback for Web and Scheduler to previous successful releases, then run smoke check again.
Record outcome and reason in your runbook/checklist.
## 9) Troubleshooting Fast Path
- Hook fires but no deploy:
- verify Auto Deploy enabled in Dokploy app
- verify hook URL in Gitea secret
- Image pull fails:
- verify Dokploy registry credentials
- verify image path/tag exists
- Health gate fails:
- check app logs in Dokploy
- verify DB connectivity/env vars
- verify NPM route and TLS

View File

@ -0,0 +1,40 @@
# DB Migration Workflow (Active Set)
## Goal
- Run only approved migration files.
- Keep a clear record of what is in scope and what is applied.
## Active Migration Manifest
- Source of truth: `packages/db/migrations/active-migrations.json`
- Current active set:
- `007_rate_limits.sql`
- `008_schedules_pivot.sql`
## Commands
- Show active-scope status:
- `npm run db:migrate:status`
- Apply active-scope migrations:
- `npm run db:migrate`
- Show full migration status:
- `npm run db:migrate:status:all`
- Apply all migration files (escape hatch):
- `npm run db:migrate:all`
## Tracking
- Applied migrations are tracked in `_migrations`.
- `db:migrate:status` reports:
- `APPLIED`
- `PENDING`
- `HASH_MISMATCH`
- applied files outside active scope
## Rule For New Migrations
1. Add the new SQL file under `packages/db/migrations/`.
2. Append the filename to `packages/db/migrations/active-migrations.json`.
3. Run `npm run db:migrate:status`.
4. Run `npm run db:migrate`.
5. Commit migration + manifest + docs update together.
## Notes
- If hash mismatch appears for stale files outside active scope, it does not block active migration runs.
- If hash mismatch appears for a file inside active scope, fix it before applying.

View File

@ -137,7 +137,8 @@ Add filtering modal with:
### API ### API
- New `/api/entries` endpoints (CRUD) - New `/api/entries` endpoints (CRUD)
- New `/api/buckets` endpoints (CRUD) - New `/api/buckets` endpoints (CRUD)
- New `/api/recurring-entries` endpoints (CRUD) - New `/api/schedules` endpoints (CRUD, canonical)
- `/api/recurring-entries` retained as compatibility alias (deprecated)
### Server ### Server
- `lib/server/entries.ts`, `buckets.ts`, `recurring-entries.ts` - `lib/server/entries.ts`, `buckets.ts`, `recurring-entries.ts`

View File

@ -0,0 +1,45 @@
# Schedules Pivot Guardrails
## Purpose
- Replace "recurring entries" with first-class "schedules".
- Keep entry creation separate from schedule templates.
- Preserve compatibility during migration.
## Terminology
- Use `Schedule` and `Schedules` in new UI and API surfaces.
- Legacy `/api/recurring-entries` remains compatibility-only and is deprecated.
## Data Model Rules
- `entries` are real posted records.
- `schedules` are templates that can materialize future entries.
- `entries.source_schedule_id` links posted entries created by schedule execution.
- Legacy recurring flags on `entries` are normalized to non-recurring in migration.
## Compatibility Window
- Canonical routes:
- `GET/POST /api/schedules`
- `PATCH/DELETE /api/schedules/[id]`
- Compatibility routes (deprecated):
- `GET/POST /api/recurring-entries`
- `PATCH/DELETE /api/recurring-entries/[id]`
## Frequency Contract
- Allowed frequencies:
- `DAILY`
- `WEEKLY`
- `MONTHLY`
- `YEARLY`
- Legacy `BIWEEKLY` and `QUARTERLY` values are mapped during migration:
- `BIWEEKLY` -> `WEEKLY` with interval doubled
- `QUARTERLY` -> `MONTHLY` with interval tripled
## Scheduler Service
- Runs as separate workspace app: `apps/scheduler`.
- Uses UTC schedule evaluation.
- Uses `FOR UPDATE SKIP LOCKED` and unique index on `(source_schedule_id, occurred_at)` for idempotency.
- Must not log secrets or sensitive payloads.
## User Settings
- User-level page size stored in `user_settings.data.entryPanelPageSize`.
- Default is `10`.
- Applied to Entries and Schedules tab "Show more" increments.

View File

@ -9,7 +9,8 @@
## 2) Deploy Control Plane (Dokploy) ## 2) Deploy Control Plane (Dokploy)
1. Install Dokploy on your Proxmox Docker host. 1. Install Dokploy on your Proxmox Docker host.
2. Add project in Dokploy and connect Gitea repository. 2. Add project in Dokploy and connect Gitea repository.
3. Configure image source: `git.nicosaya.com/nalalangan/fiddy/web`. 3. Configure web image source: `git.nicosaya.com/nalalangan/fiddy/web`.
4. Configure scheduler image source: `git.nicosaya.com/nalalangan/fiddy/scheduler`.
4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag. 4. Deploy by immutable tag (`github.sha`) and keep `main` as convenience tag.
5. Configure health check endpoint: `/api/health/ready`. 5. Configure health check endpoint: `/api/health/ready`.
6. Keep previous releases for rollback and verify rollback button path. 6. Keep previous releases for rollback and verify rollback button path.
@ -21,6 +22,8 @@
- `SESSION_COOKIE_NAME` - `SESSION_COOKIE_NAME`
- `SESSION_TTL_DAYS` - `SESSION_TTL_DAYS`
- `DEBUG_API=0` - `DEBUG_API=0`
- `SCHEDULER_POLL_MS` (scheduler app, optional)
- `SCHEDULER_BATCH_SIZE` (scheduler app, optional)
## 3) CI/CD (Gitea Actions) ## 3) CI/CD (Gitea Actions)
- Use `.gitea/workflows/deploy-dokploy.yml`. - Use `.gitea/workflows/deploy-dokploy.yml`.
@ -28,13 +31,17 @@
- `REGISTRY_USER` - `REGISTRY_USER`
- `REGISTRY_PASS` - `REGISTRY_PASS`
- `DOKPLOY_DEPLOY_HOOK` - `DOKPLOY_DEPLOY_HOOK`
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK`
- `DOKPLOY_HEALTHCHECK_URL` - `DOKPLOY_HEALTHCHECK_URL`
- Health gate: - Health gate:
- workflow calls `scripts/wait-for-health.sh` against `DOKPLOY_HEALTHCHECK_URL` - workflow calls `scripts/wait-for-health.sh` against `DOKPLOY_HEALTHCHECK_URL`
- default retry window: 5 minutes (30 attempts x 10s) - default retry window: 5 minutes (30 attempts x 10s)
## 4) Reverse Proxy + Network Hardening ## 4) Reverse Proxy + Network Hardening
- Use `docker/nginx/fiddy.conf` as baseline. - Use your existing Nginx reverse proxy/vhost.
- Apply the required Fiddy directives using `docker/nginx/fiddy.conf` and `docker/nginx/includes/fiddy-proxy.conf` as templates.
- For Nginx Proxy Manager-specific setup, follow `docs/08_NGINX_PROXY_MANAGER_SETUP.md`.
- NPM note: apply `add_header`/`proxy_set_header` in Custom Location `/` (and specific API locations), not only Proxy Host Advanced.
- Install certificate with Let's Encrypt. - Install certificate with Let's Encrypt.
- Route 443 -> app container only. - Route 443 -> app container only.
- Keep Postgres private; never expose 5432 publicly. - Keep Postgres private; never expose 5432 publicly.
@ -45,6 +52,10 @@
- Confirm Nginx writes JSON logs: - Confirm Nginx writes JSON logs:
- `/var/log/nginx/fiddy-access.log` - `/var/log/nginx/fiddy-access.log`
- `/var/log/nginx/fiddy-error.log` - `/var/log/nginx/fiddy-error.log`
- If your log paths differ, update:
- `docker/observability/promtail-config.yml`
- `docker/security/fail2ban/jail.d/fiddy-nginx.conf`
- `docker/security/crowdsec/acquis.yaml`
- Apply/verify host baseline using scripts: - Apply/verify host baseline using scripts:
- dry-run firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=1 scripts/harden-host-ufw.sh` - dry-run firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=1 scripts/harden-host-ufw.sh`
- real firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh` - real firewall apply: `SSH_ALLOW_CIDR=<your-cidr> DRY_RUN=0 sudo scripts/harden-host-ufw.sh`

13
package-lock.json generated
View File

@ -10,6 +10,14 @@
"packages/*" "packages/*"
] ]
}, },
"apps/scheduler": {
"name": "@fiddy/scheduler",
"version": "0.0.0",
"dependencies": {
"dotenv": "^16.4.5",
"pg": "^8.13.0"
}
},
"apps/web": { "apps/web": {
"name": "@fiddy/web", "name": "@fiddy/web",
"version": "0.0.0", "version": "0.0.0",
@ -980,6 +988,10 @@
"resolved": "packages/db", "resolved": "packages/db",
"link": true "link": true
}, },
"node_modules/@fiddy/scheduler": {
"resolved": "apps/scheduler",
"link": true
},
"node_modules/@fiddy/shared": { "node_modules/@fiddy/shared": {
"resolved": "packages/shared", "resolved": "packages/shared",
"link": true "link": true
@ -7110,6 +7122,7 @@
"name": "@fiddy/db", "name": "@fiddy/db",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"pg": "^8.13.0" "pg": "^8.13.0"
} }

View File

@ -10,9 +10,13 @@
"dev:web": "npm --workspace apps/web run dev", "dev:web": "npm --workspace apps/web run dev",
"build": "npm --workspace apps/web run build", "build": "npm --workspace apps/web run build",
"start": "npm --workspace apps/web run start", "start": "npm --workspace apps/web run start",
"start:scheduler": "npm --workspace apps/scheduler run start",
"lint": "npm --workspace apps/web run lint", "lint": "npm --workspace apps/web run lint",
"test": "npm --workspace apps/web run test", "test": "npm --workspace apps/web run test",
"test:ui": "npm --workspace apps/web run test:ui", "test:ui": "npm --workspace apps/web run test:ui",
"db:migrate": "npm --workspace packages/db run migrate" "db:migrate": "npm --workspace packages/db run migrate",
"db:migrate:all": "npm --workspace packages/db run migrate:all",
"db:migrate:status": "npm --workspace packages/db run status",
"db:migrate:status:all": "npm --workspace packages/db run status:all"
} }
} }

View File

@ -0,0 +1,143 @@
create type schedule_frequency as enum ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
create type schedule_end_condition as enum ('NEVER', 'AFTER_COUNT', 'BY_DATE');
create table schedules(
id bigserial primary key,
group_id bigint not null references groups(id) on delete cascade,
created_by bigint not null references users(id),
entry_type entry_type not null default 'SPENDING',
amount_dollars numeric(12,2) not null check (amount_dollars >= 0),
necessity spending_necessity not null,
purchase_type text not null,
notes text,
starts_on date not null,
frequency schedule_frequency not null,
interval_count integer not null default 1 check (interval_count > 0),
end_condition schedule_end_condition not null default 'NEVER',
end_count integer,
end_date date,
next_run_on date not null,
last_run_on date,
run_count integer not null default 0,
is_active boolean not null default true,
legacy_source_entry_id bigint references entries(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index schedules_group_next_run_idx on schedules(group_id, next_run_on);
create index schedules_created_by_idx on schedules(created_by);
create table schedule_tags(
schedule_id bigint not null references schedules(id) on delete cascade,
tag_id bigint not null references tags(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (schedule_id, tag_id)
);
create index schedule_tags_schedule_idx on schedule_tags(schedule_id);
create index schedule_tags_tag_idx on schedule_tags(tag_id);
alter table entries
add column source_schedule_id bigint references schedules(id) on delete set null;
create unique index entries_schedule_occurrence_unique
on entries(source_schedule_id, occurred_at)
where source_schedule_id is not null;
with legacy_recurring as (
select
e.id,
e.group_id,
e.created_by,
e.entry_type,
e.amount_dollars,
e.necessity,
e.purchase_type,
e.notes,
e.occurred_at,
case
when e.frequency::text = 'BIWEEKLY' then 'WEEKLY'::schedule_frequency
when e.frequency::text = 'QUARTERLY' then 'MONTHLY'::schedule_frequency
when e.frequency::text in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') then e.frequency::text::schedule_frequency
else 'MONTHLY'::schedule_frequency
end as schedule_frequency,
case
when e.frequency::text = 'BIWEEKLY' then greatest(coalesce(e.interval_count, 1), 1) * 2
when e.frequency::text = 'QUARTERLY' then greatest(coalesce(e.interval_count, 1), 1) * 3
else greatest(coalesce(e.interval_count, 1), 1)
end as safe_interval_count,
coalesce(e.end_condition::text, 'NEVER')::schedule_end_condition as schedule_end_condition,
e.end_count,
e.end_date,
coalesce(e.next_run_at, e.occurred_at) as legacy_base_date
from entries e
where e.is_recurring = true
), inserted_schedules as (
insert into schedules(
group_id,
created_by,
entry_type,
amount_dollars,
necessity,
purchase_type,
notes,
starts_on,
frequency,
interval_count,
end_condition,
end_count,
end_date,
next_run_on,
last_run_on,
run_count,
is_active,
legacy_source_entry_id
)
select
lr.group_id,
lr.created_by,
lr.entry_type,
lr.amount_dollars,
lr.necessity,
lr.purchase_type,
lr.notes,
lr.legacy_base_date as starts_on,
lr.schedule_frequency,
lr.safe_interval_count,
lr.schedule_end_condition,
lr.end_count,
lr.end_date,
case
when lr.schedule_frequency = 'DAILY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' day')::interval)::date
when lr.schedule_frequency = 'WEEKLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' week')::interval)::date
when lr.schedule_frequency = 'MONTHLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' month')::interval)::date
else (lr.legacy_base_date + (lr.safe_interval_count::text || ' year')::interval)::date
end as next_run_on,
lr.legacy_base_date as last_run_on,
1 as run_count,
case
when lr.schedule_end_condition = 'AFTER_COUNT' and coalesce(lr.end_count, 0) <= 1 then false
when lr.schedule_end_condition = 'BY_DATE' and lr.end_date is not null and lr.end_date < lr.legacy_base_date then false
else true
end as is_active,
lr.id as legacy_source_entry_id
from legacy_recurring lr
returning id, legacy_source_entry_id
)
insert into schedule_tags(schedule_id, tag_id)
select i.id, et.tag_id
from inserted_schedules i
join entry_tags et on et.entry_id = i.legacy_source_entry_id
on conflict do nothing;
update entries
set is_recurring = false,
frequency = null,
interval_count = 1,
end_condition = null,
end_count = null,
end_date = null,
next_run_at = null,
last_executed_at = null
where is_recurring = true;

View File

@ -0,0 +1,6 @@
{
"migrations": [
"007_rate_limits.sql",
"008_schedules_pivot.sql"
]
}

View File

@ -5,6 +5,9 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"migrate": "node ./src/migrate.js", "migrate": "node ./src/migrate.js",
"migrate:all": "node ./src/migrate.js --all",
"status": "node ./src/migration-status.js",
"status:all": "node ./src/migration-status.js --all",
"test": "node ./src/selftest.js", "test": "node ./src/selftest.js",
"test:ui-seed": "node ./test-ui-seed/index.js" "test:ui-seed": "node ./test-ui-seed/index.js"
}, },

View File

@ -11,6 +11,8 @@ dotenv.config({ path: path.resolve(__dirname, "../../../apps/web/.env") });
const { Pool } = pg; const { Pool } = pg;
const migrationsDir = path.resolve(__dirname, "../migrations"); const migrationsDir = path.resolve(__dirname, "../migrations");
const activeManifestPath = path.resolve(migrationsDir, "active-migrations.json");
const runAll = process.argv.includes("--all");
const sha256 = s => crypto.createHash("sha256").update(s).digest("hex"); const sha256 = s => crypto.createHash("sha256").update(s).digest("hex");
@ -53,6 +55,43 @@ async function ensureMigrationsTable(client) {
`); `);
} }
function readActiveManifest() {
if (!fs.existsSync(activeManifestPath))
return null;
const raw = fs.readFileSync(activeManifestPath, "utf8");
const parsed = JSON.parse(raw);
const listed = Array.isArray(parsed?.migrations) ? parsed.migrations : [];
const normalized = listed
.map(value => String(value || "").trim())
.filter(Boolean);
if (!normalized.length)
throw new Error("active-migrations.json must list at least one migration filename");
for (const migration of normalized) {
if (!migration.endsWith(".sql"))
throw new Error(`Invalid migration name in active manifest: ${migration}`);
const full = path.join(migrationsDir, migration);
if (!fs.existsSync(full))
throw new Error(`Migration listed in active manifest does not exist: ${migration}`);
}
return normalized;
}
function resolveMigrationFiles() {
const allFiles = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort();
if (runAll)
return allFiles;
const active = readActiveManifest();
if (!active)
return allFiles;
return active;
}
async function main() { async function main() {
requireAllowedDatabase(); requireAllowedDatabase();
@ -66,7 +105,7 @@ async function main() {
await client.query("begin"); await client.query("begin");
await ensureMigrationsTable(client); await ensureMigrationsTable(client);
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort(); const files = resolveMigrationFiles();
for (const f of files) { for (const f of files) {
const id = f; const id = f;

View File

@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import pg from "pg";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.resolve(__dirname, "../../../apps/web/.env") });
const { Pool } = pg;
const migrationsDir = path.resolve(__dirname, "../migrations");
const activeManifestPath = path.resolve(migrationsDir, "active-migrations.json");
const runAll = process.argv.includes("--all");
const sha256 = s => crypto.createHash("sha256").update(s).digest("hex");
function getDatabaseName(connectionString) {
try {
const url = new URL(connectionString);
const name = url.pathname.replace(/^\/+/, "");
return name ? decodeURIComponent(name) : "";
} catch {
return "";
}
}
function requireAllowedDatabase() {
if (!process.env.DATABASE_URL)
throw new Error("DATABASE_URL is required");
const allowedRaw = process.env.ALLOWED_DB_NAMES;
if (!allowedRaw)
throw new Error("ALLOWED_DB_NAMES is required");
const allowed = allowedRaw
.split(",")
.map(value => value.trim().toLowerCase())
.filter(Boolean);
if (!allowed.length)
throw new Error("ALLOWED_DB_NAMES must include at least one database name");
const dbName = getDatabaseName(process.env.DATABASE_URL);
if (!dbName)
throw new Error("DATABASE_URL must include a database name");
if (!allowed.includes(dbName.toLowerCase()))
throw new Error(`DATABASE_URL must target an allowed database. Found "${dbName}"`);
}
async function ensureMigrationsTable(client) {
await client.query(`
create table if not exists _migrations(
id text primary key,
hash text not null,
applied_at timestamptz not null default now()
);
`);
}
function readActiveManifest() {
if (!fs.existsSync(activeManifestPath))
return null;
const raw = fs.readFileSync(activeManifestPath, "utf8");
const parsed = JSON.parse(raw);
const listed = Array.isArray(parsed?.migrations) ? parsed.migrations : [];
const normalized = listed
.map(value => String(value || "").trim())
.filter(Boolean);
if (!normalized.length)
throw new Error("active-migrations.json must list at least one migration filename");
for (const migration of normalized) {
if (!migration.endsWith(".sql"))
throw new Error(`Invalid migration name in active manifest: ${migration}`);
const full = path.join(migrationsDir, migration);
if (!fs.existsSync(full))
throw new Error(`Migration listed in active manifest does not exist: ${migration}`);
}
return normalized;
}
function resolveMigrationFiles() {
const allFiles = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql")).sort();
if (runAll)
return allFiles;
const active = readActiveManifest();
if (!active)
return allFiles;
return active;
}
async function main() {
requireAllowedDatabase();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }
});
const client = await pool.connect();
try {
await ensureMigrationsTable(client);
const files = resolveMigrationFiles();
const appliedRows = (await client.query(
"select id, hash, applied_at from _migrations order by applied_at asc"
)).rows;
const appliedById = new Map(appliedRows.map(row => [String(row.id), row]));
console.log(`mode=${runAll ? "all" : "active"}`);
console.log(`migrations_checked=${files.length}`);
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), "utf8");
const hash = sha256(sql);
const applied = appliedById.get(f);
if (!applied) {
console.log(`[PENDING] ${f}`);
continue;
}
if (String(applied.hash) !== hash) {
console.log(`[HASH_MISMATCH] ${f} applied_at=${applied.applied_at}`);
continue;
}
console.log(`[APPLIED] ${f} applied_at=${applied.applied_at}`);
}
const scopedSet = new Set(files);
const extras = appliedRows.filter(row => !scopedSet.has(String(row.id)));
if (extras.length) {
console.log(`applied_outside_scope=${extras.length}`);
extras.forEach(row => console.log(` - ${row.id} (${row.applied_at})`));
}
} finally {
client.release();
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});