Compare commits
13 Commits
1bd550b428
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ffb909c57 | |||
| 46eb54f341 | |||
| e51b9a939c | |||
| 854528701e | |||
| 47bc092da1 | |||
| b3d32c08e9 | |||
| 1eda007d8b | |||
| 48268728f8 | |||
| 6f27f9117a | |||
| 339ab559d1 | |||
| cb87c906c8 | |||
| 86e217c040 | |||
| 2e5609955a |
@@ -15,7 +15,36 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd api
|
cd api
|
||||||
npm ci
|
npm ci
|
||||||
npm audit --omit=dev --audit-level=high
|
npm audit --omit=dev --audit-level=high --json > /tmp/skymoney-api-audit.json || true
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const report = JSON.parse(fs.readFileSync("/tmp/skymoney-api-audit.json", "utf8"));
|
||||||
|
const vulnerabilities = report.vulnerabilities || {};
|
||||||
|
const allowlisted = new Set(["fast-jwt", "@fastify/jwt"]);
|
||||||
|
const blockers = [];
|
||||||
|
|
||||||
|
for (const [name, vuln] of Object.entries(vulnerabilities)) {
|
||||||
|
const severity = String(vuln?.severity || "").toLowerCase();
|
||||||
|
if (severity !== "high" && severity !== "critical") continue;
|
||||||
|
if (allowlisted.has(name)) continue;
|
||||||
|
blockers.push({ name, severity });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
console.error("Blocking high/critical vulnerabilities found:");
|
||||||
|
for (const blocker of blockers) {
|
||||||
|
console.error(` - ${blocker.name} (${blocker.severity})`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedPresent = Object.keys(vulnerabilities).filter((name) => allowlisted.has(name));
|
||||||
|
if (allowedPresent.length > 0) {
|
||||||
|
console.warn("Allowed advisory exception(s) present:", allowedPresent.join(", "));
|
||||||
|
} else {
|
||||||
|
console.log("No allowlisted API advisories present.");
|
||||||
|
}
|
||||||
|
'
|
||||||
cd ../web
|
cd ../web
|
||||||
npm ci
|
npm ci
|
||||||
npm audit --omit=dev --audit-level=high
|
npm audit --omit=dev --audit-level=high
|
||||||
|
|||||||
2338
api/package-lock.json
generated
2338
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^7.2.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"supertest": "^6.3.4",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vitest": "^2.1.3"
|
"vitest": "^4.1.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^8.0.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -778,8 +778,20 @@ async function applyAllocations(
|
|||||||
): Promise<Array<{id: string, name: string, dueOn: Date}>> {
|
): Promise<Array<{id: string, name: string, dueOn: Date}>> {
|
||||||
// Fixed plans
|
// Fixed plans
|
||||||
const planUpdates = new Map<string, number>();
|
const planUpdates = new Map<string, number>();
|
||||||
|
const fixedAllocationRows = new Map<string, { planId: string; source: FixedAllocation["source"]; amountCents: number }>();
|
||||||
result.fixedAllocations.forEach((a) => {
|
result.fixedAllocations.forEach((a) => {
|
||||||
planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents);
|
planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents);
|
||||||
|
const rowKey = `${a.fixedPlanId}:${a.source}`;
|
||||||
|
const existing = fixedAllocationRows.get(rowKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.amountCents += a.amountCents;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fixedAllocationRows.set(rowKey, {
|
||||||
|
planId: a.fixedPlanId,
|
||||||
|
source: a.source,
|
||||||
|
amountCents: a.amountCents,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = [];
|
const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = [];
|
||||||
@@ -834,13 +846,19 @@ async function applyAllocations(
|
|||||||
needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded,
|
needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of fixedAllocationRows.values()) {
|
||||||
|
const rowAmount = Math.max(0, Math.floor(row.amountCents | 0));
|
||||||
|
if (rowAmount <= 0) continue;
|
||||||
await tx.allocation.create({
|
await tx.allocation.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
kind: "fixed",
|
kind: "fixed",
|
||||||
toId: planId,
|
toId: row.planId,
|
||||||
amountCents: BigInt(amt),
|
amountCents: BigInt(rowAmount),
|
||||||
incomeId,
|
// Available-budget pulls must not be attributed to the triggering income event.
|
||||||
|
incomeId: row.source === "income" ? incomeId : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
import { getUserMidnightFromDateOnly } from "../allocator.js";
|
import { getUserMidnightFromDateOnly } from "../allocator.js";
|
||||||
import { getUserTimezone } from "../services/user-context.js";
|
import { getUserTimezone } from "../services/user-context.js";
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ const monthKey = (date: Date) =>
|
|||||||
const monthLabel = (date: Date) =>
|
const monthLabel = (date: Date) =>
|
||||||
date.toLocaleString("en-US", { month: "short", year: "numeric" });
|
date.toLocaleString("en-US", { month: "short", year: "numeric" });
|
||||||
|
|
||||||
function buildMonthBuckets(count: number, now = new Date()) {
|
function buildMonthBuckets(count: number, pageOffset: number, now = new Date()) {
|
||||||
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
|
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
|
||||||
const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
const current = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - pageOffset * count, 1)
|
||||||
|
);
|
||||||
for (let i = count - 1; i >= 0; i--) {
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
|
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
|
||||||
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
|
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
|
||||||
@@ -23,15 +26,28 @@ function buildMonthBuckets(count: number, now = new Date()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.get("/dashboard", async (req) => {
|
app.get("/dashboard", async (req, reply) => {
|
||||||
|
const Query = z.object({
|
||||||
|
trendPage: z.coerce.number().int().min(0).default(0),
|
||||||
|
trendMonths: z.coerce.number().int().min(1).max(12).default(6),
|
||||||
|
});
|
||||||
|
const parsedQuery = Query.safeParse(req.query ?? {});
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return reply.code(400).send({ message: "Invalid dashboard query" });
|
||||||
|
}
|
||||||
|
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const monthsBack = 6;
|
const { trendPage, trendMonths } = parsedQuery.data;
|
||||||
const buckets = buildMonthBuckets(monthsBack);
|
|
||||||
const rangeStart = buckets[0]?.start ?? new Date();
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const buckets = buildMonthBuckets(trendMonths, trendPage, now);
|
||||||
|
const rangeStart = buckets[0]?.start ?? new Date();
|
||||||
|
const rangeEnd =
|
||||||
|
buckets[buckets.length - 1]?.end ??
|
||||||
|
new Date(rangeStart.getTime() + 31 * DAY_MS);
|
||||||
|
const defaultSixMonthStart = buildMonthBuckets(6, 0, now)[0]?.start ?? rangeStart;
|
||||||
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
|
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
|
||||||
|
|
||||||
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, firstIncomeEvent, spendTxs, user] = await Promise.all([
|
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, firstIncomeEvent, spendTxs, earliestSpendTx, user] = await Promise.all([
|
||||||
app.prisma.variableCategory.findMany({
|
app.prisma.variableCategory.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||||
@@ -43,7 +59,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.prisma.transaction.findMany({
|
app.prisma.transaction.findMany({
|
||||||
where: { userId, kind: { in: dashboardTxKinds } },
|
where: { userId, kind: { in: dashboardTxKinds } },
|
||||||
orderBy: { occurredAt: "desc" },
|
orderBy: { occurredAt: "desc" },
|
||||||
take: 50,
|
take: 20,
|
||||||
select: { id: true, kind: true, amountCents: true, occurredAt: true },
|
select: { id: true, kind: true, amountCents: true, occurredAt: true },
|
||||||
}),
|
}),
|
||||||
app.prisma.incomeEvent.aggregate({
|
app.prisma.incomeEvent.aggregate({
|
||||||
@@ -57,7 +73,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.prisma.incomeEvent.findMany({
|
app.prisma.incomeEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
postedAt: { gte: rangeStart },
|
postedAt: { gte: rangeStart, lt: rangeEnd },
|
||||||
},
|
},
|
||||||
select: { id: true, postedAt: true, amountCents: true, note: true },
|
select: { id: true, postedAt: true, amountCents: true, note: true },
|
||||||
}),
|
}),
|
||||||
@@ -70,10 +86,18 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
kind: { in: dashboardTxKinds },
|
kind: { in: dashboardTxKinds },
|
||||||
occurredAt: { gte: rangeStart },
|
occurredAt: { gte: rangeStart, lt: rangeEnd },
|
||||||
},
|
},
|
||||||
select: { occurredAt: true, amountCents: true },
|
select: { occurredAt: true, amountCents: true },
|
||||||
}),
|
}),
|
||||||
|
app.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
kind: { in: dashboardTxKinds },
|
||||||
|
},
|
||||||
|
orderBy: { occurredAt: "asc" },
|
||||||
|
select: { occurredAt: true },
|
||||||
|
}),
|
||||||
app.prisma.user.findUnique({
|
app.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -174,7 +198,21 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
|
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
|
||||||
spendCents: spendByMonth.get(bucket.key) ?? 0,
|
spendCents: spendByMonth.get(bucket.key) ?? 0,
|
||||||
}));
|
}));
|
||||||
|
const hasTransactionsOlderThanSixMonths =
|
||||||
|
!!earliestSpendTx?.occurredAt &&
|
||||||
|
earliestSpendTx.occurredAt.getTime() < defaultSixMonthStart.getTime();
|
||||||
|
const hasOlderTrendWindow =
|
||||||
|
hasTransactionsOlderThanSixMonths &&
|
||||||
|
!!earliestSpendTx?.occurredAt &&
|
||||||
|
earliestSpendTx.occurredAt.getTime() < rangeStart.getTime();
|
||||||
|
const startLabel = buckets[0]?.label ?? "";
|
||||||
|
const endLabel = buckets[buckets.length - 1]?.label ?? "";
|
||||||
|
const rangeLabel =
|
||||||
|
startLabel && endLabel
|
||||||
|
? startLabel === endLabel
|
||||||
|
? startLabel
|
||||||
|
: `${startLabel} - ${endLabel}`
|
||||||
|
: "Trend window";
|
||||||
const upcomingPlans = fixedPlans
|
const upcomingPlans = fixedPlans
|
||||||
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
|
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
|
||||||
.filter(
|
.filter(
|
||||||
@@ -279,6 +317,16 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
|
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
|
||||||
},
|
},
|
||||||
monthlyTrend,
|
monthlyTrend,
|
||||||
|
trendWindow: {
|
||||||
|
page: trendPage,
|
||||||
|
months: trendMonths,
|
||||||
|
canGoNewer: trendPage > 0,
|
||||||
|
canGoOlder: hasOlderTrendWindow,
|
||||||
|
hasTransactionsOlderThanSixMonths,
|
||||||
|
startLabel,
|
||||||
|
endLabel,
|
||||||
|
label: rangeLabel,
|
||||||
|
},
|
||||||
upcomingPlans,
|
upcomingPlans,
|
||||||
savingsTargets,
|
savingsTargets,
|
||||||
crisis: {
|
crisis: {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { z } from "zod";
|
|||||||
import { fromZonedTime } from "date-fns-tz";
|
import { fromZonedTime } from "date-fns-tz";
|
||||||
import { getUserMidnight } from "../allocator.js";
|
import { getUserMidnight } from "../allocator.js";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type RateLimitRouteOptions = {
|
type RateLimitRouteOptions = {
|
||||||
config: {
|
config: {
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
@@ -100,15 +102,43 @@ const paydayRoutes: FastifyPluginAsync<PaydayRoutesOptions> = async (app, opts)
|
|||||||
userTimezone
|
userTimezone
|
||||||
);
|
);
|
||||||
const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone);
|
const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone);
|
||||||
const dayStart = getUserMidnight(userTimezone, now);
|
|
||||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1);
|
// Determine the currently due pay cycle:
|
||||||
const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({
|
// - on payday: cycle starts today
|
||||||
|
// - after payday: cycle starts on the immediately prior expected payday
|
||||||
|
const oneCycleLookbackDaysByFrequency = {
|
||||||
|
weekly: 7,
|
||||||
|
biweekly: 14,
|
||||||
|
monthly: 32,
|
||||||
|
} as const;
|
||||||
|
const previousPaydayAnchor = new Date(
|
||||||
|
nextPayday.getTime() - oneCycleLookbackDaysByFrequency[user.incomeFrequency] * DAY_MS
|
||||||
|
);
|
||||||
|
const previousExpectedPayday = calculateNextPayday(
|
||||||
|
user.firstIncomeDate,
|
||||||
|
user.incomeFrequency,
|
||||||
|
previousPaydayAnchor,
|
||||||
|
userTimezone
|
||||||
|
);
|
||||||
|
const currentCycleStart = isPayday ? nextPayday : previousExpectedPayday;
|
||||||
|
const isCycleDue = now.getTime() >= currentCycleStart.getTime();
|
||||||
|
const currentCycleEnd =
|
||||||
|
currentCycleStart.getTime() === nextPayday.getTime()
|
||||||
|
? calculateNextPayday(
|
||||||
|
user.firstIncomeDate,
|
||||||
|
user.incomeFrequency,
|
||||||
|
new Date(nextPayday.getTime() + DAY_MS),
|
||||||
|
userTimezone
|
||||||
|
)
|
||||||
|
: nextPayday;
|
||||||
|
|
||||||
|
const scheduledIncomeInCurrentCycle = await app.prisma.incomeEvent.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
isScheduledIncome: true,
|
isScheduledIncome: true,
|
||||||
postedAt: {
|
postedAt: {
|
||||||
gte: dayStart,
|
gte: currentCycleStart,
|
||||||
lte: dayEnd,
|
lt: currentCycleEnd,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -120,14 +150,17 @@ const paydayRoutes: FastifyPluginAsync<PaydayRoutesOptions> = async (app, opts)
|
|||||||
firstIncomeDate: user.firstIncomeDate.toISOString(),
|
firstIncomeDate: user.firstIncomeDate.toISOString(),
|
||||||
nextPayday: nextPayday.toISOString(),
|
nextPayday: nextPayday.toISOString(),
|
||||||
isPayday,
|
isPayday,
|
||||||
|
currentCycleStart: currentCycleStart.toISOString(),
|
||||||
|
currentCycleEnd: currentCycleEnd.toISOString(),
|
||||||
|
isCycleDue,
|
||||||
pendingScheduledIncome: user.pendingScheduledIncome,
|
pendingScheduledIncome: user.pendingScheduledIncome,
|
||||||
scheduledIncomeToday: !!scheduledIncomeToday,
|
scheduledIncomeInCurrentCycle: !!scheduledIncomeInCurrentCycle,
|
||||||
shouldShowOverlay: isPayday && !scheduledIncomeToday,
|
shouldShowOverlay: isCycleDue && !scheduledIncomeInCurrentCycle,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shouldShowOverlay: isPayday && !scheduledIncomeToday,
|
shouldShowOverlay: isCycleDue && !scheduledIncomeInCurrentCycle,
|
||||||
pendingScheduledIncome: !scheduledIncomeToday,
|
pendingScheduledIncome: !scheduledIncomeInCurrentCycle,
|
||||||
nextPayday: nextPayday.toISOString(),
|
nextPayday: nextPayday.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
|||||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||||
import { allocateIncome, buildPlanStates } from "../src/allocator";
|
import { allocateIncome, buildPlanStates } from "../src/allocator";
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
describe("allocator — new funding system", () => {
|
describe("allocator — new funding system", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetUser(U);
|
await resetUser(U);
|
||||||
@@ -228,5 +230,58 @@ describe("allocator — new funding system", () => {
|
|||||||
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
|
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
|
||||||
expect(sum).toBe(0);
|
expect(sum).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not over-attribute crisis available-budget pulls to the triggering income event", async () => {
|
||||||
|
const c1 = cid("bucket");
|
||||||
|
await prisma.variableCategory.create({
|
||||||
|
data: { id: c1, userId: U, name: "Bucket", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
|
||||||
|
});
|
||||||
|
|
||||||
|
const planId = pid("crisis");
|
||||||
|
await prisma.fixedPlan.create({
|
||||||
|
data: {
|
||||||
|
id: planId,
|
||||||
|
userId: U,
|
||||||
|
name: "Urgent Rent",
|
||||||
|
cycleStart: new Date(Date.now() - DAY_MS).toISOString(),
|
||||||
|
dueOn: new Date(Date.now() + 3 * DAY_MS).toISOString(),
|
||||||
|
totalCents: 40000n,
|
||||||
|
fundedCents: 0n,
|
||||||
|
currentFundedCents: 0n,
|
||||||
|
lastFundingDate: null,
|
||||||
|
priority: 1,
|
||||||
|
fundingMode: "auto-on-deposit",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed existing available budget from prior income that was not fully allocated.
|
||||||
|
await prisma.incomeEvent.create({
|
||||||
|
data: {
|
||||||
|
id: `seed-${Date.now()}`,
|
||||||
|
userId: U,
|
||||||
|
postedAt: new Date(Date.now() - 10 * DAY_MS),
|
||||||
|
amountCents: 50000n,
|
||||||
|
isScheduledIncome: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomeId = `inc-${Date.now()}`;
|
||||||
|
const result = await allocateIncome(prisma as any, U, 1000, new Date().toISOString(), incomeId);
|
||||||
|
expect(result.crisis.pulledFromAvailableCents).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const linkedToIncome = await prisma.allocation.findMany({
|
||||||
|
where: { userId: U, incomeId },
|
||||||
|
select: { amountCents: true, kind: true },
|
||||||
|
});
|
||||||
|
const linkedSum = linkedToIncome.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0);
|
||||||
|
expect(linkedSum).toBeLessThanOrEqual(1000);
|
||||||
|
|
||||||
|
const unlinkedFixed = await prisma.allocation.findMany({
|
||||||
|
where: { userId: U, incomeId: null, kind: "fixed" },
|
||||||
|
select: { amountCents: true },
|
||||||
|
});
|
||||||
|
const unlinkedFixedSum = unlinkedFixed.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0);
|
||||||
|
expect(unlinkedFixedSum).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
136
web/package-lock.json
generated
136
web/package-lock.json
generated
@@ -11,17 +11,16 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
@@ -31,6 +30,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.3",
|
"typescript-eslint": "^8.46.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.2.2",
|
||||||
@@ -379,6 +379,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -395,6 +396,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -411,6 +413,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -427,6 +430,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -443,6 +447,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -459,6 +464,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -475,6 +481,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -491,6 +498,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -507,6 +515,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -523,6 +532,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -539,6 +549,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -555,6 +566,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -571,6 +583,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -587,6 +600,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -603,6 +617,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -619,6 +634,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -635,6 +651,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -651,6 +668,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -667,6 +685,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -683,6 +702,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -699,6 +719,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -715,6 +736,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -731,6 +753,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -747,6 +770,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -763,6 +787,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -779,6 +804,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1001,6 +1027,7 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -1011,6 +1038,7 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -1021,6 +1049,7 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -1030,12 +1059,14 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -1120,6 +1151,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1133,6 +1165,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1146,6 +1179,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1159,6 +1193,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1172,6 +1207,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1185,6 +1221,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1198,6 +1235,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1211,6 +1249,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1224,6 +1263,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1237,6 +1277,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1250,6 +1291,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1263,6 +1305,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1276,6 +1319,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1289,6 +1333,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1302,6 +1347,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1315,6 +1361,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1328,6 +1375,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1341,6 +1389,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1354,6 +1403,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1367,6 +1417,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1380,6 +1431,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1393,6 +1445,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1406,6 +1459,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1419,6 +1473,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1432,6 +1487,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1454,6 +1510,7 @@
|
|||||||
"version": "4.1.17",
|
"version": "4.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
|
||||||
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
|
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
@@ -1469,6 +1526,7 @@
|
|||||||
"version": "4.1.17",
|
"version": "4.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
|
||||||
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
|
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
@@ -1495,6 +1553,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1511,6 +1570,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1527,6 +1587,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1543,6 +1604,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1559,6 +1621,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1575,6 +1638,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1591,6 +1655,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1607,6 +1672,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1623,6 +1689,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1647,6 +1714,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1668,6 +1736,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1684,6 +1753,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1697,6 +1767,7 @@
|
|||||||
"version": "4.1.17",
|
"version": "4.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
|
||||||
"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
|
"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/node": "4.1.17",
|
"@tailwindcss/node": "4.1.17",
|
||||||
@@ -1874,6 +1945,7 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
@@ -1887,7 +1959,7 @@
|
|||||||
"version": "20.19.25",
|
"version": "20.19.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
||||||
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2774,6 +2846,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2790,6 +2863,7 @@
|
|||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
@@ -2820,6 +2894,7 @@
|
|||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3209,9 +3284,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -3219,6 +3294,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -3269,6 +3345,7 @@
|
|||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
@@ -3405,6 +3482,7 @@
|
|||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -3505,6 +3583,7 @@
|
|||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -3537,6 +3616,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3557,6 +3637,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3577,6 +3658,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3597,6 +3679,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3617,6 +3700,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3637,6 +3721,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3657,6 +3742,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3677,6 +3763,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3697,6 +3784,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3717,6 +3805,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3737,6 +3826,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3794,6 +3884,7 @@
|
|||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -3847,6 +3938,7 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3979,12 +4071,13 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3998,6 +4091,7 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -4238,6 +4332,7 @@
|
|||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -4358,6 +4453,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4407,12 +4503,14 @@
|
|||||||
"version": "4.1.17",
|
"version": "4.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4446,6 +4544,7 @@
|
|||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4462,6 +4561,7 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -4476,9 +4576,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -4604,7 +4705,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
@@ -4683,6 +4784,7 @@
|
|||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -5270,6 +5372,7 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -5284,9 +5387,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -15,17 +15,16 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.3",
|
"typescript-eslint": "^8.46.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.2.2",
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ export type DashboardResponse = {
|
|||||||
incomeCents: number;
|
incomeCents: number;
|
||||||
spendCents: number;
|
spendCents: number;
|
||||||
}>;
|
}>;
|
||||||
|
trendWindow?: {
|
||||||
|
page: number;
|
||||||
|
months: number;
|
||||||
|
canGoNewer: boolean;
|
||||||
|
canGoOlder: boolean;
|
||||||
|
hasTransactionsOlderThanSixMonths: boolean;
|
||||||
|
startLabel: string;
|
||||||
|
endLabel: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>;
|
upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>;
|
||||||
savingsTargets: Array<{
|
savingsTargets: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,10 +89,22 @@ export type DashboardResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDashboard(enabled = true) {
|
export type DashboardQueryOptions = {
|
||||||
|
trendPage?: number;
|
||||||
|
trendMonths?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDashboard(enabled = true, options?: DashboardQueryOptions) {
|
||||||
|
const trendPage = Math.max(0, options?.trendPage ?? 0);
|
||||||
|
const trendMonths = Math.min(12, Math.max(1, options?.trendMonths ?? 6));
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard"],
|
queryKey: ["dashboard", trendPage, trendMonths],
|
||||||
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
|
queryFn: () =>
|
||||||
|
apiGet<DashboardResponse>("/dashboard", {
|
||||||
|
trendPage,
|
||||||
|
trendMonths,
|
||||||
|
}),
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowse
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const session = useAuthSession();
|
const session = useAuthSession();
|
||||||
const shouldLoadDashboard = !!session.data?.userId;
|
const shouldLoadDashboard = !!session.data?.userId;
|
||||||
const { data, isLoading, isError, error, refetch } = useDashboard(
|
const trendMonths = 6;
|
||||||
|
const [trendPage, setTrendPage] = useState(0);
|
||||||
|
const { data, isLoading, isError, isFetching, error, refetch } = useDashboard(
|
||||||
shouldLoadDashboard,
|
shouldLoadDashboard,
|
||||||
|
{
|
||||||
|
trendPage,
|
||||||
|
trendMonths,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
@@ -479,6 +485,12 @@ export default function DashboardPage() {
|
|||||||
const hasCategories = data.variableCategories.length > 0;
|
const hasCategories = data.variableCategories.length > 0;
|
||||||
const hasPlans = data.fixedPlans.length > 0;
|
const hasPlans = data.fixedPlans.length > 0;
|
||||||
const hasTx = data.recentTransactions.length > 0;
|
const hasTx = data.recentTransactions.length > 0;
|
||||||
|
const hasTransactionsOlderThanSixMonths =
|
||||||
|
data.trendWindow?.hasTransactionsOlderThanSixMonths ?? false;
|
||||||
|
const useTrendWindowPagination = hasTransactionsOlderThanSixMonths;
|
||||||
|
const trendWindowLabel = data.trendWindow?.label ?? "Last 6 months";
|
||||||
|
const canTrendNewer = data.trendWindow?.canGoNewer ?? trendPage > 0;
|
||||||
|
const canTrendOlder = data.trendWindow?.canGoOlder ?? true;
|
||||||
|
|
||||||
const greetingName =
|
const greetingName =
|
||||||
data.user.displayName ||
|
data.user.displayName ||
|
||||||
@@ -887,12 +899,25 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "trend" && (
|
{activeTab === "trend" && (
|
||||||
<section className="grid gap-6 lg:grid-cols-2">
|
<section className="space-y-4">
|
||||||
<MonthlyTrendChart data={data.monthlyTrend} />
|
{useTrendWindowPagination ? (
|
||||||
<RecentTransactionsPanel
|
<TrendWindowControls
|
||||||
transactions={data.recentTransactions}
|
label={trendWindowLabel}
|
||||||
hasData={hasTx}
|
canGoOlder={canTrendOlder}
|
||||||
/>
|
canGoNewer={canTrendNewer}
|
||||||
|
busy={isFetching}
|
||||||
|
onOlder={() => setTrendPage((p) => p + 1)}
|
||||||
|
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<MonthlyTrendChart data={data.monthlyTrend} />
|
||||||
|
<RecentTransactionsPanel
|
||||||
|
transactions={data.recentTransactions}
|
||||||
|
hasData={hasTx}
|
||||||
|
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1077,10 +1102,21 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === "trend" && (
|
{activeTab === "trend" && (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
|
{useTrendWindowPagination ? (
|
||||||
|
<TrendWindowControls
|
||||||
|
label={trendWindowLabel}
|
||||||
|
canGoOlder={canTrendOlder}
|
||||||
|
canGoNewer={canTrendNewer}
|
||||||
|
busy={isFetching}
|
||||||
|
onOlder={() => setTrendPage((p) => p + 1)}
|
||||||
|
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<MonthlyTrendChart data={data.monthlyTrend} />
|
<MonthlyTrendChart data={data.monthlyTrend} />
|
||||||
<RecentTransactionsPanel
|
<RecentTransactionsPanel
|
||||||
transactions={data.recentTransactions}
|
transactions={data.recentTransactions}
|
||||||
hasData={hasTx}
|
hasData={hasTx}
|
||||||
|
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1118,6 +1154,44 @@ function AnalyticsTabButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrendWindowControls({
|
||||||
|
label,
|
||||||
|
canGoOlder,
|
||||||
|
canGoNewer,
|
||||||
|
busy,
|
||||||
|
onOlder,
|
||||||
|
onNewer,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
canGoOlder: boolean;
|
||||||
|
canGoNewer: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onOlder: () => void;
|
||||||
|
onNewer: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="row items-center gap-2 rounded-xl border px-3 py-2 bg-[--color-panel]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={onNewer}
|
||||||
|
disabled={!canGoNewer || busy}
|
||||||
|
>
|
||||||
|
← Newer
|
||||||
|
</button>
|
||||||
|
<div className="text-sm muted ml-1">{label}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ml-auto"
|
||||||
|
onClick={onOlder}
|
||||||
|
disabled={!canGoOlder || busy}
|
||||||
|
>
|
||||||
|
Older →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UpcomingPlanAlerts({
|
function UpcomingPlanAlerts({
|
||||||
plans,
|
plans,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
@@ -1128,18 +1202,18 @@ function UpcomingPlanAlerts({
|
|||||||
if (plans.length === 0) return null;
|
if (plans.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10">
|
<section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
|
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-50">
|
||||||
Upcoming plan alerts (next 14 days)
|
Upcoming plan alerts (next 14 days)
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<li
|
<li
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-[--color-ink]/20 dark:text-[--color-text] sm:flex-row sm:items-center sm:justify-between"
|
className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-200/15 dark:text-amber-50 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-amber-950 dark:text-white">{plan.name}</div>
|
<div className="font-medium text-amber-950 dark:text-white">{plan.name}</div>
|
||||||
<div className="text-xs muted">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
Due{" "}
|
Due{" "}
|
||||||
{formatDateInTimezone(plan.dueOn, userTimezone, {
|
{formatDateInTimezone(plan.dueOn, userTimezone, {
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -1148,7 +1222,7 @@ function UpcomingPlanAlerts({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs muted">Remaining</div>
|
<div className="text-xs text-amber-700 dark:text-amber-200">Remaining</div>
|
||||||
<Money cents={plan.remainingCents} />
|
<Money cents={plan.remainingCents} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1203,14 +1277,19 @@ function SavingsGoalsPanel({
|
|||||||
function RecentTransactionsPanel({
|
function RecentTransactionsPanel({
|
||||||
transactions,
|
transactions,
|
||||||
hasData,
|
hasData,
|
||||||
|
rangeLabel,
|
||||||
}: {
|
}: {
|
||||||
transactions: DashboardResponse["recentTransactions"];
|
transactions: DashboardResponse["recentTransactions"];
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
|
rangeLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const title = rangeLabel ? "Transactions in window" : "Recent transactions";
|
||||||
|
const visibleTransactions = transactions.slice(0, 10);
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="font-semibold">Recent transactions</h2>
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
message="No transactions yet"
|
message="No transactions yet"
|
||||||
actionLabel="Record one"
|
actionLabel="Record one"
|
||||||
@@ -1221,7 +1300,8 @@ function RecentTransactionsPanel({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="font-semibold">Recent transactions</h2>
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
|
||||||
|
|
||||||
{/* Desktop table view */}
|
{/* Desktop table view */}
|
||||||
<div className="hidden sm:block border rounded-xl overflow-x-auto">
|
<div className="hidden sm:block border rounded-xl overflow-x-auto">
|
||||||
@@ -1234,7 +1314,7 @@ function RecentTransactionsPanel({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{transactions.map((tx) => (
|
{visibleTransactions.map((tx) => (
|
||||||
<tr key={tx.id} className="border-t">
|
<tr key={tx.id} className="border-t">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{new Date(tx.occurredAt).toLocaleString()}
|
{new Date(tx.occurredAt).toLocaleString()}
|
||||||
@@ -1253,7 +1333,7 @@ function RecentTransactionsPanel({
|
|||||||
|
|
||||||
{/* Mobile card view */}
|
{/* Mobile card view */}
|
||||||
<div className="sm:hidden space-y-2">
|
<div className="sm:hidden space-y-2">
|
||||||
{transactions.map((tx) => (
|
{visibleTransactions.map((tx) => (
|
||||||
<div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3">
|
<div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -1276,6 +1356,11 @@ function RecentTransactionsPanel({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link to="/transactions" className="btn text-sm">
|
||||||
|
View more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function TransactionsPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
|
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
|
||||||
const [catFilter, setCatFilter] = useState<string>("all");
|
const [catFilter, setCatFilter] = useState<string>("all");
|
||||||
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
|
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("all");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance";
|
import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance";
|
||||||
import CurrencyInput from "../../components/CurrencyInput";
|
import CurrencyInput from "../../components/CurrencyInput";
|
||||||
import { useToast } from "../../components/Toast";
|
import { useToast } from "../../components/Toast";
|
||||||
|
|
||||||
|
const REBALANCE_SCROLL_NUDGE_SEEN_KEY = "rebalance.scroll.nudge.v1";
|
||||||
|
|
||||||
function sum(values: number[]) {
|
function sum(values: number[]) {
|
||||||
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
|
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,8 @@ export default function RebalancePage() {
|
|||||||
const [adjustId, setAdjustId] = useState<string>("");
|
const [adjustId, setAdjustId] = useState<string>("");
|
||||||
const [adjustValue, setAdjustValue] = useState<string>("");
|
const [adjustValue, setAdjustValue] = useState<string>("");
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const tableScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [scrollCue, setScrollCue] = useState({ left: false, right: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.categories) {
|
if (data?.categories) {
|
||||||
@@ -60,6 +64,66 @@ export default function RebalancePage() {
|
|||||||
}
|
}
|
||||||
}, [data?.categories]);
|
}, [data?.categories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (window.localStorage.getItem(REBALANCE_SCROLL_NUDGE_SEEN_KEY) === "1") return;
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const el = tableScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
let backTimer: number | null = null;
|
||||||
|
const raf = window.requestAnimationFrame(() => {
|
||||||
|
const hasHorizontalOverflow = el.scrollWidth > el.clientWidth + 8;
|
||||||
|
if (!hasHorizontalOverflow) return;
|
||||||
|
|
||||||
|
const nudgeDistance = Math.min(64, Math.max(24, Math.round(el.clientWidth * 0.2)));
|
||||||
|
const startLeft = el.scrollLeft;
|
||||||
|
el.scrollTo({ left: startLeft + nudgeDistance, behavior: "smooth" });
|
||||||
|
backTimer = window.setTimeout(() => {
|
||||||
|
el.scrollTo({ left: startLeft, behavior: "smooth" });
|
||||||
|
}, 700);
|
||||||
|
window.localStorage.setItem(REBALANCE_SCROLL_NUDGE_SEEN_KEY, "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
if (backTimer !== null) window.clearTimeout(backTimer);
|
||||||
|
};
|
||||||
|
}, [rows.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const el = tableScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
const updateCue = () => {
|
||||||
|
const hasOverflow = el.scrollWidth > el.clientWidth + 8;
|
||||||
|
if (!hasOverflow) {
|
||||||
|
setScrollCue((prev) => (prev.left || prev.right ? { left: false, right: false } : prev));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const left = el.scrollLeft > 4;
|
||||||
|
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
|
||||||
|
setScrollCue((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollOrResize = () => {
|
||||||
|
if (rafId) window.cancelAnimationFrame(rafId);
|
||||||
|
rafId = window.requestAnimationFrame(updateCue);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCue();
|
||||||
|
el.addEventListener("scroll", handleScrollOrResize, { passive: true });
|
||||||
|
window.addEventListener("resize", handleScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId) window.cancelAnimationFrame(rafId);
|
||||||
|
el.removeEventListener("scroll", handleScrollOrResize);
|
||||||
|
window.removeEventListener("resize", handleScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [rows.length]);
|
||||||
|
|
||||||
const available = data?.availableCents ?? 0;
|
const available = data?.availableCents ?? 0;
|
||||||
const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]);
|
const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]);
|
||||||
const savingsTotal = useMemo(
|
const savingsTotal = useMemo(
|
||||||
@@ -259,40 +323,61 @@ export default function RebalancePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto rounded-2xl border border-[--color-border]/50 bg-[--color-panel]">
|
<div className="relative">
|
||||||
<table className="w-full text-sm">
|
<div
|
||||||
<thead className="bg-[--color-surface]">
|
ref={tableScrollRef}
|
||||||
<tr className="text-left">
|
className="overflow-auto rounded-2xl border border-[--color-border]/50 bg-[--color-panel]"
|
||||||
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Category</th>
|
>
|
||||||
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Current</th>
|
<table className="w-full text-sm">
|
||||||
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Percent</th>
|
<thead className="bg-[--color-surface]">
|
||||||
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Target</th>
|
<tr className="text-left">
|
||||||
</tr>
|
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Category</th>
|
||||||
</thead>
|
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Current</th>
|
||||||
<tbody>
|
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Percent</th>
|
||||||
{rows.map((row) => (
|
<th className="py-3 px-4 text-xs uppercase tracking-wide muted">Target</th>
|
||||||
<tr key={row.id} className="border-t border-[--color-border]/30">
|
|
||||||
<td className="py-3 px-4 font-medium flex items-center gap-2">
|
|
||||||
{row.name}
|
|
||||||
{row.isSavings ? <span className="badge badge-ghost">Savings</span> : null}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
|
|
||||||
<td className="py-3 px-4">{row.percent}%</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<CurrencyInput
|
|
||||||
className="input w-32"
|
|
||||||
valueCents={row.targetCents}
|
|
||||||
onChange={(cents) =>
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-t border-[--color-border]/30">
|
||||||
|
<td className="py-3 px-4 font-medium flex items-center gap-2">
|
||||||
|
{row.name}
|
||||||
|
{row.isSavings ? <span className="badge badge-ghost">Savings</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
|
||||||
|
<td className="py-3 px-4">{row.percent}%</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<CurrencyInput
|
||||||
|
className="input w-32"
|
||||||
|
valueCents={row.targetCents}
|
||||||
|
onChange={(cents) =>
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{scrollCue.left && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[--color-panel] to-transparent"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{scrollCue.right && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-0 flex w-12 items-center justify-end bg-gradient-to-l from-[--color-panel] to-transparent pr-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="rounded border border-[--color-border] bg-[--color-bg]/80 px-1.5 py-0.5 text-[11px] font-semibold text-[--color-muted]">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user