diff --git a/README.md b/README.md index 5e058db..31787a2 100755 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ no purchases. Decimal places are one setting for the whole budget. Import and reset clear session undo history after they succeed. The month page shows Actual available beside Spent: entered income minus actual -spending for that month. Show chart and Expected's Expand/Collapse controls -remember their state in this browser. Add purchase can create a new recurring +spending for that month. Tick posts in Monthly breakdown to filter spending; +select one post to see its purchases. Monthly breakdown, Expected, Budget, and +Purchases have Expand/Collapse controls that remember their state in this +browser. Add purchase can create a new recurring cost; enable choosing existing bills under Settings → Recurring costs. ## Known limitations diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 00eeab1..9911a93 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -164,11 +164,18 @@ record. ## Design system -- **Chart visibility and Expected's expanded state persist per browser.** +- **Monthly breakdown, Expected, Budget, and Purchases expand independently + and persist per browser.** Chosen over resetting on every visit or moving display choices with a JSON budget export, following the theme's storage boundary. → [`specs/2026-10-03-month-and-purchase-refinements.md`](specs/2026-10-03-month-and-purchase-refinements.md) +- **Spending chart selection changes the chart's denominator only.** Each + post remains available to check again; selecting one shows its purchase + shares, using the existing monthly charge calculation. Refunds are separate + from positive slices. Budget figures never change when a post is unchecked. + → [`specs/2026-10-03-month-and-purchase-refinements.md`](specs/2026-10-03-month-and-purchase-refinements.md) + - **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's entire variable set points at it, so every primitive inherits both themes. → [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md) diff --git a/docs/TODO.md b/docs/TODO.md index 05d3645..24db4c1 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -10,7 +10,9 @@ task moved to `AGENTS.md`. **Current state:** the month and purchase refinements landed in `c250bbe`, building on the merged anchored model, budget folders and session backups. -911 tests pass across 66 files; +The spending chart now supports post selection and purchase detail, and Monthly +breakdown, Expected, Budget, and Purchases have remembered collapse controls. +915 tests pass across 67 files; `bunx tsc --noEmit` is clean, and `SKIP_FX_FETCH=1 bun run build` succeeds using the committed seed rates. diff --git a/docs/specs/2026-10-03-month-and-purchase-refinements.md b/docs/specs/2026-10-03-month-and-purchase-refinements.md index f42af13..2285f69 100644 --- a/docs/specs/2026-10-03-month-and-purchase-refinements.md +++ b/docs/specs/2026-10-03-month-and-purchase-refinements.md @@ -94,3 +94,30 @@ Desktop and mobile captures cover light and dark themes. Screenshots establish layout and console behavior; visual feel still requires the owner. The complete suite passes 911 tests across 66 files, TypeScript is clean, and production builds succeed at both root and subpath base URLs with seed rates. + +## Follow-up: post selection and section collapse + +The owner clarified that chart visibility should also operate on individual +posts, so a large post can be excluded to inspect smaller spending. Every post +with actual charge activity, including Unassigned and a zero net total made +from purchases and refunds, starts selected. Unchecked posts remain available; +Select all and Clear recover from any selection. Net spent and percentages +reflect the selected posts, leaving all budget figures unchanged. With exactly +one selected post, the chart shows that post's individual purchase shares for +the month. The existing charge and slice functions provide split amounts, +instalments, FX conversion, and dataset precision. Refunds remain separate from +positive slices. + +The Show chart checkbox is replaced by Expected's Expand/Collapse button style. +Budget and Purchases also receive independent remembered controls. All four +sections start expanded, retaining the previous chart visibility preference. +The post selection lasts for the current month-page visit and does not enter +the exported dataset. Budget and Purchases hide mounted content to preserve +editing drafts; Add purchase remains reachable while Purchases is collapsed. + +Regression checks cover filtering, single-post purchase details, instalment +shares, FX at zero decimals, refunds cancelling to zero, no selection, restoring +all posts, independent collapse state, mounted drafts and reachable actions. +The complete suite now passes 915 tests across 67 files, TypeScript and the +production build pass, and Chrome verifies filtering, drilldown and collapse +controls on mobile without horizontal overflow or uncaught exceptions. diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index 490498b..79e45e6 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -1,5 +1,4 @@ import { useId, useMemo, useState } from "react"; -import { ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDataset } from "../hooks/useDataset.ts"; @@ -10,7 +9,7 @@ import { confirmOccurrence } from "../../store/actions.ts"; import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts"; import { addMonths, compareMonths } from "../../domain/months.ts"; import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts"; -import { Section } from "./Section.tsx"; +import { Section, SectionToggle } from "./Section.tsx"; import { useDisplayPreference } from "../hooks/useDisplayPreference.ts"; import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts"; @@ -328,10 +327,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { hint={expanded ? "Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards." : `${pending.length} expected · ${comingUp.length} coming up · ${overdue} overdue`} - action={} + action={ setExpanded(!expanded)} />} > {error &&

Could not confirm: {error}

}
diff --git a/src/ui/components/MonthPieShowcase.test.tsx b/src/ui/components/MonthPieShowcase.test.tsx index 48fff18..792c27f 100644 --- a/src/ui/components/MonthPieShowcase.test.tsx +++ b/src/ui/components/MonthPieShowcase.test.tsx @@ -21,16 +21,16 @@ test("hiding the breakdown stays remembered after remount and leaves its control const render = () => root.render(); try { act(render); - const toggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + const toggle = container.querySelector('button[aria-controls]') as HTMLButtonElement; expect(toggle).not.toBeNull(); - expect(toggle.checked).toBe(true); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); act(() => toggle.click()); expect(container.querySelector('svg[role="img"]')).toBeNull(); act(() => root.unmount()); root = createRoot(container); act(render); - const remembered = container.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(remembered.checked).toBe(false); + const remembered = container.querySelector('button[aria-controls]') as HTMLButtonElement; + expect(remembered.getAttribute("aria-expanded")).toBe("false"); act(() => remembered.click()); expect(container.querySelector('svg[role="img"]')).not.toBeNull(); } finally { @@ -63,3 +63,37 @@ test("pie showcase switches between spending and allocations", () => { Object.assign(store, original); } }); + +test("post checkboxes filter spending, reveal purchase detail, and stay reachable with none selected", () => { + localStorage.removeItem("budget2:show-month-breakdown"); + const data = createSeedDataset("2026-09"); + data.purchases = [ + { id: "small", date: "2026-09", description: "Small game", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], schedule: null }, + { id: "large", date: "2026-09", description: "Large shop", total: { amount: 900, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }], schedule: null }, + ]; + const original = { get: store.get, subscribe: store.subscribe }; + store.get = () => data; + store.subscribe = () => () => {}; + const container = document.createElement("div"); + const root = createRoot(container); + try { + act(() => root.render()); + const food = container.querySelector('input[aria-label="Include Food"]') as HTMLInputElement; + act(() => food.click()); + expect(container.querySelector('svg title')?.textContent).toContain("Small game"); + expect(container.textContent).toContain("Purchases in Video Games"); + const games = container.querySelector('input[aria-label="Include Video Games"]') as HTMLInputElement; + act(() => games.click()); + expect(container.textContent).toContain("No posts selected"); + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2); + const all = [...container.querySelectorAll("button")].find(button => button.textContent === "Select all")!; + act(() => all.click()); + expect(food.checked).toBe(true); + expect(games.checked).toBe(true); + expect(container.textContent).toContain("950.00 DKK"); + } finally { + act(() => root.unmount()); + Object.assign(store, original); + localStorage.removeItem("budget2:show-month-breakdown"); + } +}); diff --git a/src/ui/components/MonthPieShowcase.tsx b/src/ui/components/MonthPieShowcase.tsx index 597a5c3..ca81780 100644 --- a/src/ui/components/MonthPieShowcase.tsx +++ b/src/ui/components/MonthPieShowcase.tsx @@ -1,10 +1,12 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import type { MonthViewModel } from "../../domain/views.ts"; -import { monthPieData, type MonthPieMode } from "../monthPie.ts"; +import { monthPieData, spendingPieData, type MonthPieMode } from "../monthPie.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; -import { Section } from "./Section.tsx"; +import { Section, SectionToggle } from "./Section.tsx"; import { Segmented } from "./Segmented.tsx"; import { useDisplayPreference } from "../hooks/useDisplayPreference.ts"; +import { useDataset } from "../hooks/useDataset.ts"; +import { Button } from "@/components/ui/button"; const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"]; const OPTIONS = [ @@ -26,30 +28,34 @@ function wedge(start: number, end: number): string { export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) { const [mode, setMode] = useState("spending"); const [visible, setVisible] = useDisplayPreference("budget2:show-month-breakdown", true); + const [excluded, setExcluded] = useState>(() => new Set()); + const contentId = useId(); + const dataset = useDataset(); const fmt = useMoneyFormat(); - const chart = monthPieData(view, mode); + const spending = spendingPieData(dataset, view.monthId, excluded); + const chart = mode === "spending" ? spending.chart : monthPieData(view, mode); let angle = 0; const label = mode === "spending" ? "spending" : "allocation"; return (
- {visible && } + setVisible(!visible)} /> } > - {visible &&
+
+ {visible &&
{chart.positiveTotal === 0 ? ( ) : chart.positive.length === 1 ? ( - + + {`${chart.positive[0]!.label}: ${fmt.money(chart.positive[0]!.amount, baseCurrency)}`} + ) : chart.positive.map((item, index) => { const start = angle; angle += (item.amount / chart.positiveTotal) * 360; @@ -69,14 +75,42 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
- {mode === "spending" ? "Net spent" : "Total allocated"} + {mode === "spending" ? spending.selectedCount === spending.posts.length ? "Net spent" : "Selected net spent" : "Total allocated"} {fmt.money(chart.netTotal, baseCurrency)}
+ {mode === "spending" && spending.posts.length > 0 &&
+ Posts included in spending chart +
+ {spending.selectedCount} of {spending.posts.length} selected +
+ + +
+
+
+ {spending.posts.map(post => )} +
+
} + {mode === "spending" && spending.focusLabel &&

Purchases in {spending.focusLabel}

} {chart.positive.length === 0 ? (

- {mode === "spending" ? "No positive spending to chart." : "No positive allocations to chart."} + {mode === "spending" ? spending.posts.length > 0 && spending.selectedCount === 0 ? "No posts selected. Tick a post or select all to show spending." : "No positive spending to chart." : "No positive allocations to chart."}

- ) : ( + ) : mode === "allocation" || spending.focusLabel ? (
    {chart.positive.map((item, index) => (
  1. @@ -86,7 +120,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
  2. ))}
- )} + ) : null} {chart.adjustments.length > 0 && (

Refunds and negative adjustments are excluded from pie slices.

@@ -102,6 +136,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; )}
} +
); } diff --git a/src/ui/components/Section.test.tsx b/src/ui/components/Section.test.tsx new file mode 100644 index 0000000..64821be --- /dev/null +++ b/src/ui/components/Section.test.tsx @@ -0,0 +1,44 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +import { expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { CollapsibleSection } from "./Section.tsx"; + +test("sections collapse independently, preserve drafts and actions, and remember their state", () => { + const budgetKey = "budget2:test-expand-budget"; + const purchasesKey = "budget2:test-expand-purchases"; + localStorage.removeItem(budgetKey); + localStorage.removeItem(purchasesKey); + const container = document.createElement("div"); + let root = createRoot(container); + const render = () => root.render(<> + + Add purchase}>

Purchase rows

+ ); + try { + act(render); + const input = container.querySelector("input")!; + input.value = "Unsaved draft"; + const collapse = container.querySelector('button[aria-label="Collapse Budget"]') as HTMLButtonElement; + act(() => collapse.click()); + expect(input.closest("[hidden]")).not.toBeNull(); + expect(container.querySelector('button[aria-label="Collapse Purchases"]')).not.toBeNull(); + act(() => (container.querySelector('button[aria-label="Expand Budget"]') as HTMLButtonElement).click()); + expect(input.value).toBe("Unsaved draft"); + expect(input.closest("[hidden]")).toBeNull(); + act(() => (container.querySelector('button[aria-label="Collapse Purchases"]') as HTMLButtonElement).click()); + const add = [...container.querySelectorAll("button")].find(button => button.textContent === "Add purchase")!; + expect(add.closest("[hidden]")).toBeNull(); + act(() => root.unmount()); + root = createRoot(container); + act(render); + expect(container.querySelector('button[aria-label="Collapse Budget"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Expand Purchases"]')).not.toBeNull(); + } finally { + act(() => root.unmount()); + localStorage.removeItem(budgetKey); + localStorage.removeItem(purchasesKey); + } +}); diff --git a/src/ui/components/Section.tsx b/src/ui/components/Section.tsx index 12aa3f0..b5140f5 100644 --- a/src/ui/components/Section.tsx +++ b/src/ui/components/Section.tsx @@ -1,4 +1,7 @@ -import type { ReactNode } from "react"; +import { useId, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useDisplayPreference } from "../hooks/useDisplayPreference.ts"; interface Props { /** Omitted when the card's contents are self-explanatory (a toolbar). */ @@ -46,6 +49,30 @@ export function Section({ title, action, hint, children, className = "" }: Props ); } +export function SectionToggle({ title, expanded, contentId, onToggle }: { + title: string; + expanded: boolean; + contentId: string; + onToggle: () => void; +}) { + return ; +} + +/** Hide rather than unmount editors so collapsing cannot discard a draft. */ +export function CollapsibleSection({ preferenceKey, title, hint, action, children, ...props }: Props & { title: string; preferenceKey: string }) { + const [expanded, setExpanded] = useDisplayPreference(preferenceKey, true); + const contentId = useId(); + return
+ {action} + setExpanded(!expanded)} /> + }> + +
; +} + /** * A single figure with its label. Used for the month's totals, where the * numbers matter more than the words: the label is small and quiet, the figure diff --git a/src/ui/monthPie.test.ts b/src/ui/monthPie.test.ts index 2c4bc84..5bc9755 100644 --- a/src/ui/monthPie.test.ts +++ b/src/ui/monthPie.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { createSeedDataset } from "../domain/seed.ts"; import { monthView } from "../domain/views.ts"; -import { monthPieData } from "./monthPie.ts"; +import { monthPieData, spendingPieData } from "./monthPie.ts"; test("spending pie separates positive slices from refunds and includes unassigned charges", () => { const data = createSeedDataset("2026-09"); @@ -28,3 +28,38 @@ test("allocation pie reflects the month view and has an honest empty state", () expect(chart.positive.map(({ label, amount }) => [label, amount])).toEqual([["Video Games", 250]]); expect(chart.netTotal).toBe(250); }); + +test("spending selection excludes large posts and drills into monthly purchase shares", () => { + const data = createSeedDataset("2026-09"); + const [games, food] = data.posts; + data.purchases = [ + { id: "split", date: "2026-08", description: "Shared order", total: { amount: 200, currency: "DKK" }, splitMode: "percent", splits: [{ postId: games!.id, value: 25, absorbsRemainder: false }, { postId: food!.id, value: 75, absorbsRemainder: true }], schedule: { slices: [{ month: "2026-08", amount: { amount: 100, currency: "DKK" } }, { month: "2026-09", amount: { amount: 100, currency: "DKK" } }] } }, + { id: "refund", date: "2026-09", description: "Returned game", total: { amount: -5, currency: "DKK" }, splitMode: "percent", splits: [{ postId: games!.id, value: 100, absorbsRemainder: true }], schedule: null }, + { id: "big", date: "2026-09", description: "Big shop", total: { amount: 900, currency: "DKK" }, splitMode: "percent", splits: [{ postId: food!.id, value: 100, absorbsRemainder: true }], schedule: null }, + { id: "loose", date: "2026-09", description: "Loose", total: { amount: 10, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null }, + ]; + const all = spendingPieData(data, "2026-09", new Set()); + expect(all.chart.netTotal).toBe(1005); + const filtered = spendingPieData(data, "2026-09", new Set([`post:${food!.id}`, "unassigned"])); + expect(filtered.focusLabel).toBe(games!.name); + expect(filtered.chart.positive.map(item => [item.label, item.amount])).toEqual([["Shared order", 25]]); + expect(filtered.chart.adjustments.map(item => [item.label, item.amount])).toEqual([["Returned game", -5]]); + expect(filtered.chart.netTotal).toBe(20); + expect(filtered.posts).toHaveLength(3); + const empty = spendingPieData(data, "2026-09", new Set(all.posts.map(item => item.id))); + expect(empty.chart.netTotal).toBe(0); + expect(empty.chart.positive).toEqual([]); +}); + +test("purchase detail keeps offsetting refunds visible and uses converted dataset precision", () => { + const data = createSeedDataset("2026-09"); + data.settings.digits = 0; + const postId = data.posts[0]!.id; + data.fxRates = [{ currency: "USD", baseUnitsPerOne: 7.3, updatedAt: "2026-09-01", source: "manual" }]; + data.purchases = [10, -10].map((amount, index) => ({ id: String(index), date: "2026-09", description: index ? "Refund" : "Bought", total: { amount, currency: "USD" }, splitMode: "percent", splits: [{ postId, value: 100, absorbsRemainder: true }], schedule: null })); + const result = spendingPieData(data, "2026-09", new Set()); + expect(result.posts).toHaveLength(1); + expect(result.chart.netTotal).toBe(0); + expect(result.chart.positive[0]!.amount).toBe(73); + expect(result.chart.adjustments[0]!.amount).toBe(-73); +}); diff --git a/src/ui/monthPie.ts b/src/ui/monthPie.ts index ca85909..35bbbb6 100644 --- a/src/ui/monthPie.ts +++ b/src/ui/monthPie.ts @@ -1,4 +1,8 @@ import type { MonthViewModel } from "../domain/views.ts"; +import type { Dataset, MonthId } from "../domain/types.ts"; +import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts"; +import { toBase } from "../domain/fx.ts"; +import { roundMoney } from "../domain/money.ts"; export type MonthPieMode = "spending" | "allocation"; export interface MonthPieSlice { @@ -7,6 +11,49 @@ export interface MonthPieSlice { amount: number; } +/** Read-only chart selection; shares reuse the same charge boundaries as the fold. */ +export function spendingPieData(dataset: Dataset, monthId: MonthId, excluded: ReadonlySet) { + const groups = new Map(); + const { baseCurrency, digits } = dataset.settings; + const labels = new Map(dataset.posts.map(post => [post.id, post.name])); + for (const purchase of dataset.purchases) { + const slice = sliceAmountForMonth(purchase, monthId); + if (!slice) continue; + const charges = purchase.splits.length === 0 + ? [{ id: "unassigned", label: "Unassigned", amount: toBase(slice, baseCurrency, dataset.fxRates, digits) }] + : chargesForPurchaseInMonth(purchase, monthId, baseCurrency, dataset.fxRates, digits) + .map(charge => ({ id: `post:${charge.postId}`, label: labels.get(charge.postId) ?? charge.postId, amount: charge.amount })); + for (const charge of charges) { + if (charge.amount === 0) continue; + let group = groups.get(charge.id); + if (!group) { + group = { post: { ...charge, amount: 0 }, purchases: [] }; + groups.set(charge.id, group); + } + group.post.amount = roundMoney(group.post.amount + charge.amount, digits); + const existing = group.purchases.find(item => item.id === purchase.id); + if (existing) existing.amount = roundMoney(existing.amount + charge.amount, digits); + else group.purchases.push({ id: purchase.id, label: purchase.description || "Untitled purchase", amount: charge.amount }); + } + } + const posts = [...groups.values()].map(group => group.post) + .sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label)); + const selected = posts.filter(post => !excluded.has(post.id)); + const focus = selected.length === 1 ? groups.get(selected[0]!.id)! : null; + const items = focus ? focus.purchases : selected; + return { + posts, + selectedCount: selected.length, + focusLabel: focus?.post.label ?? null, + chart: { + positive: items.filter(item => item.amount > 0).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label)), + adjustments: items.filter(item => item.amount < 0).sort((a, b) => a.amount - b.amount), + positiveTotal: roundMoney(items.reduce((sum, item) => sum + Math.max(0, item.amount), 0), digits), + netTotal: roundMoney(selected.reduce((sum, item) => sum + item.amount, 0), digits), + }, + }; +} + /** Chart data comes from the same folded figures as the month table. */ export function monthPieData(view: MonthViewModel, mode: MonthPieMode) { const items: MonthPieSlice[] = view.rows.map(({ post, figures }) => ({ diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 8890752..7400a1a 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -19,7 +19,7 @@ import { MonthPieShowcase } from "../components/MonthPieShowcase.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; import { ExpectedBand } from "../components/ExpectedBand.tsx"; import { Meter } from "../components/Meter.tsx"; -import { Section, Stat } from "../components/Section.tsx"; +import { Section, CollapsibleSection, Stat } from "../components/Section.tsx"; import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts"; import { groupPurchasesByDate } from "../purchaseGroups.ts"; @@ -141,13 +141,14 @@ export function MonthRoute() { -
} className="overflow-hidden" > -
+ {changingPost && ( )} -
Add purchase} />} > {groups.length === 0 ? ( @@ -193,7 +195,7 @@ export function MonthRoute() { ))}
)} - + ); }