diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bdab4df6..93b7ec60 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,8 +9,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 name: Install PNPM - with: - version: 9.0.5 - uses: actions/setup-node@v4 with: node-version: '22.x' @@ -24,8 +22,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 name: Install PNPM - with: - version: 9.0.5 - uses: actions/setup-node@v4 with: node-version: '22.x' diff --git a/apps/website/eslint.config.js b/apps/website/eslint.config.js index b0a4f7d5..1f82ab8c 100644 --- a/apps/website/eslint.config.js +++ b/apps/website/eslint.config.js @@ -1,60 +1,60 @@ -import typescript from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import svelte from "eslint-plugin-svelte"; -import svelteParser from "svelte-eslint-parser"; +import typescript from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import svelte from 'eslint-plugin-svelte'; +import svelteParser from 'svelte-eslint-parser'; export default [ { ignores: [ - "**/build/**", - "**/coverage/**", - "**/drizzle/**", - "**/test-results/**", - "**/node_modules/**", - "**/.svelte-kit/**", - "**/.vercel/**", + '**/build/**', + '**/coverage/**', + '**/drizzle/**', + '**/test-results/**', + '**/node_modules/**', + '**/.svelte-kit/**', + '**/.vercel/**', ], }, { - files: ["**/*.ts", "**/*.tsx", "**/*.js"], + files: ['**/*.ts', '**/*.tsx', '**/*.js'], languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.json", - sourceType: "module", + project: './tsconfig.json', + sourceType: 'module', ecmaVersion: 2022, }, }, plugins: { - "@typescript-eslint": typescript, + '@typescript-eslint': typescript, }, rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], }, }, { - files: ["**/*.svelte"], + files: ['**/*.svelte'], languageOptions: { parser: svelteParser, parserOptions: { parser: tsParser, - project: "./tsconfig.json", - extraFileExtensions: [".svelte"], + project: './tsconfig.json', + extraFileExtensions: ['.svelte'], }, }, plugins: { svelte: svelte, - "@typescript-eslint": typescript, + '@typescript-eslint': typescript, }, rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], }, }, diff --git a/apps/website/package.json b/apps/website/package.json index 24b75403..ece4aacc 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -34,9 +34,9 @@ "@storybook/test": "8.6.14", "@storybook/test-runner": "^0.23.0", "@sungmanito/skeleton-plugin": "workspace:*", - "@sveltejs/adapter-vercel": "^4.0.5", - "@sveltejs/kit": "^2.47.0", - "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@sveltejs/adapter-vercel": "^6.3.3", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", "@testing-library/jest-dom": "^6.6.3", @@ -58,13 +58,13 @@ "sass": "^1.89.2", "storybook": "9.1.1", "supabase": "^1.226.4", - "svelte": "^5.36.5", + "svelte": "^5.54.1", "svelte-check": "^4.2.2", "tailwindcss": "^3.4.17", "tslib": "^2.8.1", "typescript": "^5.8.3", - "vite": "^7.1.10", - "vitest": "^1.6.1" + "vite": "^8.0.1", + "vitest": "^4.1.0" }, "type": "module", "dependencies": { diff --git a/apps/website/postcss.config.cjs b/apps/website/postcss.config.cjs index 12a703d9..289bdce0 100644 --- a/apps/website/postcss.config.cjs +++ b/apps/website/postcss.config.cjs @@ -1,6 +1,38 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, +// lightningcss 1.32.0 incorrectly rejects some ::file-selector-button selectors +// generated by @tailwindcss/forms. This plugin normalizes them before minification. +const USER_ACTION_PSEUDOS = new Set([':hover', ':active', ':focus', ':focus-within', ':focus-visible']); + +const fixFileSelectorButton = () => ({ + postcssPlugin: 'fix-file-selector-button', + Rule(rule) { + if (!rule.selector.includes('::file-selector-button')) return; + + // Remove rules with a combinator after ::file-selector-button — truly invalid CSS. + if (/::file-selector-button\s*[>~+]/.test(rule.selector)) { + rule.remove(); + return; + } + + // Move any non-user-action pseudo-classes from after ::file-selector-button to before it. + // e.g. ::file-selector-button:disabled → :disabled::file-selector-button + // ::file-selector-button:disabled:hover → :disabled:hover::file-selector-button + rule.selector = rule.selector.replace( + /(::file-selector-button)((?::[a-z-]+)+)/g, + (_, pseudoEl, pseudoClasses) => { + const classes = pseudoClasses.match(/:[a-z-]+/g) || []; + const before = classes.filter((c) => !USER_ACTION_PSEUDOS.has(c)); + const after = classes.filter((c) => USER_ACTION_PSEUDOS.has(c)); + return before.join('') + pseudoEl + after.join(''); + }, + ); }, +}); +fixFileSelectorButton.postcss = true; + +module.exports = { + plugins: [ + require('tailwindcss'), + require('autoprefixer'), + fixFileSelectorButton, + ], }; diff --git a/apps/website/src/app.css b/apps/website/src/app.css index 95b3ae01..5b243639 100644 --- a/apps/website/src/app.css +++ b/apps/website/src/app.css @@ -9,3 +9,7 @@ top: 0; left: 0; } + +.no-scroll { + overflow: hidden; +} diff --git a/apps/website/src/app.d.ts b/apps/website/src/app.d.ts index b0bab0d1..c1fbd73a 100644 --- a/apps/website/src/app.d.ts +++ b/apps/website/src/app.d.ts @@ -1,6 +1,5 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces -import type { getUserHouseholds } from '$lib/server/actions/households.actions'; import { SupabaseClient, type Session } from '@supabase/supabase-js'; import type { PostHog } from 'posthog-node'; declare global { @@ -14,7 +13,6 @@ declare global { supabase: SupabaseClient; getSession: () => Promise; config: VercelConfig; - userHouseholds: Awaited>; posthog: PostHog; } // interface PageData {} diff --git a/apps/website/src/hooks.server.ts b/apps/website/src/hooks.server.ts index 08e3ca62..8fd23fcb 100644 --- a/apps/website/src/hooks.server.ts +++ b/apps/website/src/hooks.server.ts @@ -4,8 +4,6 @@ import { POSTHOG_API_KEY, } from '$env/static/private'; import { PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { getUserHouseholds } from '$lib/server/actions/households.actions'; -import { validateUserSession } from '$lib/util/session'; import { createServerClient } from '@supabase/ssr'; import { redirect, type Handle } from '@sveltejs/kit'; import { PostHog } from 'posthog-node'; @@ -27,10 +25,13 @@ export const handle: Handle = async ({ event, resolve }) => { cookies: { get: (key) => event.cookies.get(key), set: (key, value, options) => { - event.cookies.set(key, value, options); + event.cookies.set(key, value, { + ...options, + path: options.path ?? '/', + }); }, remove: (key, options) => { - event.cookies.delete(key, options); + event.cookies.delete(key, { ...options, path: options.path ?? '/' }); }, }, }, @@ -63,15 +64,6 @@ export const handle: Handle = async ({ event, resolve }) => { redirect(303, `/login?url=${event.url.pathname}`); } - // We are gathering the logged in users' households a lot - // To hopefully save that, we store them in the locals. - if (validateUserSession(session)) { - console.info('Gathering user households'); - event.locals.userHouseholds = await getUserHouseholds(session.user.id); - } else { - event.locals.userHouseholds = []; - } - return resolve(event, { filterSerializedResponseHeaders(name) { return name === 'content-range'; diff --git a/apps/website/src/lib/components/buttonGroup/buttonGroup.stories.svelte b/apps/website/src/lib/components/buttonGroup/buttonGroup.stories.svelte index b431768d..e0e9ff6c 100644 --- a/apps/website/src/lib/components/buttonGroup/buttonGroup.stories.svelte +++ b/apps/website/src/lib/components/buttonGroup/buttonGroup.stories.svelte @@ -6,7 +6,6 @@ const { Story } = defineMeta({ title: 'Components/Button Group', component: ButtonGroup, - tags: ['autodocs'], }); let fn = $state('Option 1'); diff --git a/apps/website/src/lib/components/card/card.stories.svelte b/apps/website/src/lib/components/card/card.stories.svelte new file mode 100644 index 00000000..6826689a --- /dev/null +++ b/apps/website/src/lib/components/card/card.stories.svelte @@ -0,0 +1,64 @@ + + +{#snippet defaultChildren()} +
Card content
+{/snippet} + + + + + + {#snippet header()} +
Summary info
+
+ +
+ {/snippet} +
0
+
+
+ + + + {#snippet header()} + Placekitten + {/snippet} + + {#snippet footer()} +
+ +
+ {/snippet} + +
This is Whiskers. He's a wanted criminal in 7 states.
+
+
diff --git a/apps/website/src/lib/components/card/card.svelte b/apps/website/src/lib/components/card/card.svelte new file mode 100644 index 00000000..bb818f0c --- /dev/null +++ b/apps/website/src/lib/components/card/card.svelte @@ -0,0 +1,48 @@ + + + + +
+ {#if header} +
+ {@render header()} +
+ {/if} + {#if children} + {@render children()} + {/if} + {#if footer} +
+ {@render footer()} +
+ {/if} +
diff --git a/apps/website/src/lib/components/chart/chart.svelte b/apps/website/src/lib/components/chart/chart.svelte index d29c0e56..8f05ccab 100644 --- a/apps/website/src/lib/components/chart/chart.svelte +++ b/apps/website/src/lib/components/chart/chart.svelte @@ -58,8 +58,6 @@ } }; }); - - $inspect(chartInstance);
diff --git a/apps/website/src/lib/components/drawer/drawer.svelte b/apps/website/src/lib/components/drawer/drawer.svelte index 206594d2..da74156f 100644 --- a/apps/website/src/lib/components/drawer/drawer.svelte +++ b/apps/website/src/lib/components/drawer/drawer.svelte @@ -6,10 +6,12 @@ onopen?: () => void; children: Snippet<[{ close: () => void }]>; from?: 'left' | 'right' | 'top' | 'bottom'; + stopScroll?: boolean; } @@ -121,8 +102,9 @@ onclose={() => { history.go(-1); invalidate('household:payments'); - billsWithStatus.refetch(); + billsByStatus.refetch(); }} + id={makeOrUpdatePayment.url.split('/').at(-1)} /> void; }>} - onopen={() => { - console.info('OPENED'); - }} - onclose={() => { - console.info('OKAY'); - }} + id={createBillDetailsDrawer.url.split('/').at(-1)} /> -
- -
-

-
🧾
-
Dashboard
-

-
-
Total Outstanding
-
- {(totalOutstanding || 0).toLocaleString(undefined, { - style: 'currency', - currency: 'USD', - })} + + {#snippet pending()} +
+
+
+ {#each Array(3) as _} +
+ {/each} +
+
+
+ {#each Array(5) as _} +
+ {/each} +
+
-
+ {/snippet} - -
- {#each summary as s} -
-

- {s.label} -
- {#if typeof s.icon === 'string'} - {s.icon} - {:else} + {@const summary = [ + { + label: 'Paid This Month', + value: billsByStatus.data.paid.length, + icon: CheckIcon, + iconBg: 'bg-green-500', + iconText: 'text-green-700', + }, + { + label: 'Overdue', + value: billsByStatus.data.overdue.length, + icon: TriangleAlertIcon, + iconBg: 'bg-red-500', + iconText: 'text-red-700', + }, + { + label: 'Due This Week', + value: billsByStatus.data.upcoming.length, + icon: WatchIcon, + iconBg: 'bg-blue-500', + iconText: 'text-blue-500', + }, + ]} + +
+ +
+

+
🧾
+
Dashboard
+

+
+
Total Outstanding
+
+ {(0).toLocaleString(undefined, { + style: 'currency', + currency: 'USD', + })} +
+
+
+ + +
+ {#each summary as s} +
+

+ {s.label} +
- {/if} +
+

+
+ {s.value}
-

-
- {s.value}
-
- {/each} -
+ {/each} +
- -
- -
-
-
-

Recent Bills

-
- {#if billsWithStatus.isStale} - - {/if} + +
+ +
+
+
+

+ Recent Bills +

+
+ {#if billsByStatus.isStale} + + {/if} - + -
- - - +
+ + + + +
-
- {:else}

There are no bills that match this filter

{/each} - {:else if billsWithStatus.isLoading} -
 
- {/if} +
-
- -
-
-

- Quick Actions -

- -
- - + ➕ Add New Bill + +
+ + +
-
+ diff --git a/apps/website/src/routes/dashboard/bills/+page.server.ts b/apps/website/src/routes/dashboard/bills/+page.server.ts deleted file mode 100644 index f2a2be50..00000000 --- a/apps/website/src/routes/dashboard/bills/+page.server.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { - getUserBills, - type BillInsertArgs, -} from '$lib/server/actions/bills.actions.js'; -import { db } from '$lib/server/db'; -import { validateUserSession } from '$lib/util/session.js'; -import { validateFormData } from '@jhecht/arktype-utils'; -import { exportedSchema } from '@sungmanito/db'; -import { error, fail, redirect } from '@sveltejs/kit'; -import { scope, type } from 'arktype'; -import { and, eq, inArray } from 'drizzle-orm'; - -function padCalendar(inp: string | number) { - return `0${inp}`.slice(-2); -} - -export const load = async ({ locals, depends }) => { - const session = await locals.getSession(); - - depends('user:bills'); - - if (!validateUserSession(session)) { - redirect(300, '/login'); - } - - const bills = await getUserBills(session.user.id); - - return { - bills: bills, - households: locals.userHouseholds, - }; -}; - -export const actions = { - addBill: async ({ locals, request }) => { - const session = await locals.getSession(); - if (!validateUserSession(session)) error(401); - - const validator = scope({ - DueDate: '1<=number<=28', - formData: { - name: 'string[]', - 'household-id': 'string[]', - 'due-date': 'DueDate[]', - }, - }).type('formData'); - - let formData: typeof validator.infer; - - try { - formData = validateFormData(await request.formData(), validator); - } catch (e) { - if (e instanceof type.errors) - return fail(400, { - message: e.issues, - }); - console.error(e); - return fail(500); - } - - // convert the households to a set to thin out repeated entries. - // convert back to array to utilize the .every method - const submittedHouseholds = Array.from( - new Set(formData['household-id']), - ); - - // If the user is not a member of the household, yeet an error - if ( - !submittedHouseholds.every( - (id) => - locals.userHouseholds.findIndex((h) => h.households.id === id) !== -1, - ) - ) - return fail(401, { - message: 'Cannot add bill to household you are not a member of', - }); - - const insertBills: BillInsertArgs[] = Array.from( - { length: formData['household-id'].length }, - (_, i) => ({ - billName: formData.name[i], - dueDate: formData['due-date'][i], - householdId: formData['household-id'][i], - }), - ); - - const bills = await db.transaction(async (tx) => { - const newBills = await tx - .insert(exportedSchema.bills) - .values(insertBills) - .returning(); - - const rightNow = new Date(); - - const payments = await tx - .insert(exportedSchema.payments) - .values( - newBills.map((bill) => ({ - billId: bill.id, - forMonthD: new Date( - `${rightNow.getFullYear()}-${padCalendar(rightNow.getMonth() + 1)}-${padCalendar(bill.dueDate).slice(-2)}T00:00:00Z`, - ), - householdId: bill.householdId, - })), - ) - .returning(); - - if (newBills.length === 0 || payments.length === 0) { - tx.rollback(); - } - - return newBills; - }); - - return { - bills, - }; - }, - updateBill: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!session || !session?.user) error(401, 'nope'); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-id': 'string', - 'bill-name': 'string', - 'household-id': 'string', - 'due-date': '1<=number<=28', - 'amount?': 'number>=0', - 'currency?': 'string>=3 & /[A-Z]{3}/', - }), - ); - - const userHouseholds = locals.userHouseholds; - - if ( - userHouseholds.findIndex( - (uh) => uh.households.id === data['household-id'], - ) === -1 - ) - error(400, 'Not authorized'); - - const [response] = await db - .update(exportedSchema.bills) - .set({ - billName: data['bill-name'], - dueDate: data['due-date'], - householdId: data['household-id'], - amount: data['amount'] - ? Math.max(0, Number(data['amount'])) - : undefined, - currency: data['currency'] ? data['currency'].toUpperCase() : undefined, - }) - .where(eq(exportedSchema.bills.id, data['bill-id'])) - .returning(); - - if (response === undefined) error(400); - - return { - status: 200, - bill: response, - }; - }, - deleteBill: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!validateUserSession(session)) error(401); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-id': 'string[]', - }), - ); - - const billIds = [...new Set(data['bill-id'])]; - if (billIds.length === 0) - return fail(400, { message: 'No bill ids provided' }); - - const [deleted] = await db - .delete(exportedSchema.bills) - .where( - and( - inArray(exportedSchema.bills.id, billIds), - inArray( - exportedSchema.bills.householdId, - locals.userHouseholds.map((h) => h.households.id), - ), - ), - ) - .returning(); - if (!deleted) error(400, 'Bill not found'); - - return { - bill: deleted, - }; - }, - deleteMultipleBills: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!validateUserSession(session)) error(401); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-ids': 'string[]', - }), - ); - - // Normalize and validate billIds: ensure array of non-empty strings, dedupe, fail if empty - let billIds = data['bill-ids']; - if (!Array.isArray(billIds)) billIds = [billIds]; - billIds = billIds - .map((id) => (typeof id === 'string' ? id.trim() : '')) - .filter((id) => id.length > 0); - - billIds = Array.from(new Set(billIds)); - - if (billIds.length === 0) { - return fail(400, { message: 'No valid bill ids provided' }); - } - - const deletedBills = await db - .delete(exportedSchema.bills) - .where( - and( - inArray(exportedSchema.bills.id, billIds), - inArray( - exportedSchema.bills.householdId, - locals.userHouseholds.map((h) => h.households.id), - ), - ), - ) - .returning(); - - return { - deletedBills, - deletedCount: deletedBills.length, - }; - }, -}; diff --git a/apps/website/src/routes/dashboard/bills/+page.svelte b/apps/website/src/routes/dashboard/bills/+page.svelte index aaa81f61..3a2024a5 100644 --- a/apps/website/src/routes/dashboard/bills/+page.svelte +++ b/apps/website/src/routes/dashboard/bills/+page.svelte @@ -1,5 +1,5 @@ @@ -88,208 +74,254 @@ onclose={async () => { editBillStore.show = false; replaceState('/dashboard/bills', {}); - selectedBills = []; - await queryClient.invalidateQueries({ - queryKey: ['drawerify', editBillStore.url], - }); - await invalidateAll(); + selectedBillIds = []; + getUserBills().refresh(); }} - component={ShowBillDetailsComponent as Component<{ + component={EditBillDetailsComponent as Component<{ data: unknown; component: boolean; onclose: () => void; }>} + ids={selectedBillIds} /> - - { - deleteModalOpen = false; - selectedBills = []; - }} - action="?/deleteBill" -> - {#snippet header()} - Delete Bills? - {/snippet} - {#snippet footer()} - + + {#snippet pending()} +
+
+
+ {#each Array(3) as _} +
+ {#each Array(4) as _} +
+ {/each} + {/each} +
{/snippet} - {#each selectedBills as bill (bill.id)} - - {/each} -
-

- Are you sure you want to delete {selectedBills.length} bills? -

-

- This action cannot be undone, and will delete all payment history - associated with this bill. -

-
-
- { - showBillDetailsStore.show = false; - history.replaceState(null, '', '/dashboard/bills'); - }} - component={ShowBillDetailsComponent as Component<{ - data: unknown; - component: boolean; - onclose: () => void; - }>} -/> + {@const bills = await getUserBills()} + {@const byHousehold = bills.reduce( + (acc, bill) => { + if (!acc[bill.householdName]) { + acc[bill.householdName] = []; + } + acc[bill.householdName].push(bill); + return acc; + }, + {} as Record, + )} + + +
{ + await submit(); + deleteModalOpen = false; + selectedBillIds = []; + })} + > + {#each selectedBillIds as id} + + {/each} + { + deleteModalOpen = false; + selectedBillIds = []; + }} + > + {#snippet header()} + Delete Bills? + {/snippet} + {#snippet footer()} + + {/snippet} +
+

+ Are you sure you want to delete + {selectedBillIds.length} + bills? +

+

+ This action cannot be undone, and will delete all payment history + associated with this bill. +

+
+
+
-
- { + showBillDetailsStore.show = false; + history.replaceState(null, '', '/dashboard/bills'); + }} + component={BillDetailsComponent as Component<{ + data: unknown; + component: boolean; + onclose: () => void; + }>} + id={showBillDetailsStore.url.split('/').at(-1)} /> -
- Bills - {#snippet actions()} - - - - {/snippet} -
-
- {#each Object.entries(byHousehold) as [householdName, bills]} -
- {#snippet actions()} - {@const isIndeterminate = (() => { - const selectedBillsSet = new Set(selectedBills); - const theseBills = new Set(bills); - const difference = theseBills.difference(selectedBillsSet); - const intersection = selectedBillsSet.intersection(theseBills); - return ( - intersection.size > 0 && - selectedBillsSet.size > 0 && - difference.size > 0 - ); - })()} - {@const checked = bills.every((bill) => selectedBills.includes(bill))} - { - const selectedBillsSet = new Set(selectedBills); - const theseBills = new Set(bills); - const diff = theseBills.difference(selectedBillsSet); - if (diff.size === 0) { - // All bills are selected, so we need to deselect them - bills.forEach((bill) => selectedBillsSet.delete(bill)); - } else { - // Some or none of the bills are selected, so we need to select them - diff.forEach((bill) => selectedBillsSet.add(bill)); - } - selectedBills = Array.from(selectedBillsSet); - }} - indeterminate={isIndeterminate} - {checked} - /> - {/snippet} - {householdName} -
- {#each bills as bill (bill.id)} -
+ +
+ Bills + {#snippet actions()} + + + + {/snippet} +
+
+ {#each Object.entries(byHousehold) as [householdName, householdBills]} +
+ {#snippet actions()} + {@const isIndeterminate = (() => { + const selectedSet = new Set(selectedBillIds); + const theseBillIds = new Set(householdBills.map((b) => b.id)); + const difference = theseBillIds.difference(selectedSet); + const intersection = selectedSet.intersection(theseBillIds); + return ( + intersection.size > 0 && + selectedSet.size > 0 && + difference.size > 0 + ); + })()} + {@const checked = householdBills.every((bill) => + selectedBillIds.includes(bill.id), )} -

-
- - -
-
+ indeterminate={isIndeterminate} + {checked} + /> + {/snippet} + + {householdName} + + {#each householdBills as bill (bill.id)} +
+
+ { + e.preventDefault(); + history.pushState(null, '', `/dashboard/bills/${bill.id}`); + }, + })} + > + {bill.billName} + + + {#snippet actions()} + { + const set = new Set(selectedBillIds); + if (set.has(bill.id)) { + set.delete(bill.id); + } else { + set.add(bill.id); + } + selectedBillIds = Array.from(set); + }} + checked={selectedBillIds.includes(bill.id)} + aria-label={`Select ${bill.billName}`} + /> + {/snippet} +
+

+ {bill.householdName} – {bill.dueDate}{ordinalSuffix( + bill.dueDate, + )} +

+
+ + +
+
+ {/each} {/each} - {/each} +
-
+ diff --git a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts deleted file mode 100644 index 00b8d7b9..00000000 --- a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getBill } from '$lib/server/actions/bills.actions'; -import { db } from '$lib/server/db/client'; -import { validateUserSession } from '$lib/util/session'; -import { error } from '@sveltejs/kit'; - -export const load = async ({ locals, params }) => { - const session = await locals.getSession(); - if (!validateUserSession(session)) error(401); - - const bill = await getBill(params.id); - - return { - bill, - payments: db.query.payments.findMany({ - where(fields, operators) { - return operators.eq(fields.billId, params.id); - }, - orderBy(fields, operators) { - return operators.desc(fields.forMonthD); - }, - // Full year's worth - limit: 12, - }), - }; -}; diff --git a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte index 8dcf02ac..29c3a41b 100644 --- a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte +++ b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte @@ -1,24 +1,31 @@ - - Dashboard – {data.bill.billName} - + Dashboard – Bills (paymentDetails.url = '')} /> -
-
- {#if !component} - - {/if} -
- {data.bill.billName} - {#snippet actions()} - {#if component} - - {/if} - {/snippet} -
+ + {#snippet pending()} +
+
+
+
+
+ {#each Array(5) as _} +
+ {/each} +
+
+ {/snippet} - -
Details
-
-
Household
- -
Due Date
-
{data.bill.dueDate}{ordinalSuffix(data.bill.dueDate)}
-
Notes
-
- {#if data.bill.notes} - {data.bill.notes} - {:else} - N/A + {@const billData = await getBillWithPayments(id)} + +
+
+ {#if !component} + + {/if} +
+ {billData.billName} + {#snippet actions()} + {#if component} + {/if} + {/snippet} +
+ + +
Details
+
+
Household
+ +
Due Date
+
{billData.dueDate}{ordinalSuffix(billData.dueDate)}
+
Notes
+
+ {#if billData.notes} + {billData.notes} + {:else} + N/A + {/if} +
+
Amount
+
{formatNumber(billData.amount)}
+
Currency
+
{billData.currency}
-
Amount
-
- {formatNumber(data.bill.amount)} -
-
Currency
-
- {data.bill.currency} -
-
- + - - {#snippet header()} -
Payment History
- {/snippet} - {#await data.payments} -
- {:then payments} - {@const labels = payments + + {#snippet header()} +
Payment History
+ {/snippet} + {@const labels = billData.payments + .slice() .sort((a, b) => a.forMonthD.getTime() - b.forMonthD.getTime()) .map((p) => p.forMonthD.toLocaleDateString(undefined, { @@ -126,16 +137,20 @@ Number(p.amount) ?? null), - label: `${data.bill.billName} (Actual)`, + data: billData.payments + .slice() + .sort((a, b) => a.forMonthD.getTime() - b.forMonthD.getTime()) + .map((p) => Number(p.amount) ?? null), + label: `${billData.billName} (Actual)`, type: 'line', }, { data: Array.from( - { length: payments.length }, - () => data.bill.amount || 0, + { length: billData.payments.length }, + () => billData.amount || 0, ), label: 'Minimum', type: 'line', @@ -144,40 +159,22 @@ ]} options={{ scales: { - y: { - grid: { - color: '#e9e9e950', - }, - }, - x: { - grid: { - color: '#eee', - }, - }, + y: { grid: { color: '#e9e9e950' } }, + x: { grid: { color: '#eee' } }, }, }} /> - {/await} -
+
- {#await data.payments} -
-
-
-
-
- {:then payments}
- {#each payments as payment (payment.id)} + {#each billData.payments as payment (payment.id)}
{ e.preventDefault(); - // @ts-expect-error can't turn this shit off rn - - pushState(e.target.href, {}); + pushState(`/dashboard/payments/${payment.id}`, {}); showPaymentDetails(payment.id); }} > @@ -214,9 +211,9 @@ No payment history yet {/each}
- {/await} +
-
+