diff --git a/app/Http/Controllers/DocumentationController.php b/app/Http/Controllers/DocumentationController.php new file mode 100644 index 00000000..c64e23dc --- /dev/null +++ b/app/Http/Controllers/DocumentationController.php @@ -0,0 +1,285 @@ +defaultSlug(); + $page = $this->page($slug); + $markdown = File::get($page['file']); + + return Inertia::render('documentation/show', [ + 'document' => [ + 'slug' => $slug, + 'title' => $page['title'], + 'description' => $page['description'], + 'html' => $this->html($markdown), + ], + 'navigation' => $this->navigation($slug), + ]); + } + + private function defaultSlug(): string + { + $slug = config('documentation.default'); + + if (! is_string($slug) || $slug === '') { + throw new NotFoundHttpException; + } + + return $slug; + } + + /** + * @return array{title: string, description: string, file: string} + */ + private function page(string $slug): array + { + $page = config("documentation.pages.{$slug}"); + + if (! is_array($page) || ! isset($page['title'], $page['description'], $page['file'])) { + throw new NotFoundHttpException; + } + + if (! File::exists($page['file'])) { + throw new NotFoundHttpException; + } + + return [ + 'title' => (string) $page['title'], + 'description' => (string) $page['description'], + 'file' => (string) $page['file'], + ]; + } + + private function html(string $markdown): string + { + $headings = $this->headings($markdown); + $html = (string) Str::of($markdown)->markdown([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + $html = $this->replaceTocPlaceholder($html, $headings); + + return $this->addHeadingIds($html, $headings); + } + + /** + * @return array + */ + private function headings(string $markdown): array + { + preg_match_all('/^(#{1,6})\s+(.+?)\s*#*\s*$/m', $markdown, $matches, PREG_SET_ORDER); + + $headings = []; + $usedSlugs = []; + $levels = $this->tocLevels(); + + foreach ($matches as $match) { + $level = strlen($match[1]); + + if (! in_array($level, $levels, true)) { + continue; + } + + $title = $this->plainHeadingText($match[2]); + + $headings[] = [ + 'level' => $level, + 'title' => $title, + 'id' => $this->uniqueHeadingId($title, $usedSlugs), + ]; + } + + return $headings; + } + + /** + * @return array + */ + private function tocLevels(): array + { + $levels = config('documentation.toc.levels', [2, 3]); + + if (! is_array($levels)) { + return [2, 3]; + } + + return collect($levels) + ->map(fn (mixed $level): int => (int) $level) + ->filter(fn (int $level): bool => $level >= 1 && $level <= 6) + ->unique() + ->values() + ->all(); + } + + private function plainHeadingText(string $heading): string + { + $html = (string) Str::of($heading)->inlineMarkdown([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + /** + * @param array $usedSlugs + */ + private function uniqueHeadingId(string $title, array &$usedSlugs): string + { + $base = Str::slug($title); + + if ($base === '') { + $base = 'section'; + } + + $usedSlugs[$base] = ($usedSlugs[$base] ?? 0) + 1; + + if ($usedSlugs[$base] === 1) { + return $base; + } + + return "{$base}-{$usedSlugs[$base]}"; + } + + /** + * @param array $headings + */ + private function replaceTocPlaceholder(string $html, array $headings): string + { + $placeholder = config('documentation.toc.placeholder', '{{TOC}}'); + + if (! is_string($placeholder) || $placeholder === '') { + return $html; + } + + return str_replace( + ["

{$placeholder}

", $placeholder], + $this->tocHtml($headings), + $html, + ); + } + + /** + * @param array $headings + */ + private function tocHtml(array $headings): string + { + if ($headings === []) { + return ''; + } + + $items = collect($this->numberedHeadings($headings)) + ->map(fn (array $heading): string => sprintf( + '
  • %s %s
  • ', + $heading['level'], + e($heading['id']), + e($heading['number']), + e($heading['title']), + )) + ->implode(''); + + return ''; + } + + /** + * @param array $headings + * @return array + */ + private function numberedHeadings(array $headings): array + { + $currentH2 = 0; + $currentH3 = 0; + + return collect($headings) + ->map(function (array $heading) use (&$currentH2, &$currentH3): array { + if ($heading['level'] === 2) { + $currentH2++; + $currentH3 = 0; + + return [...$heading, 'number' => (string) $currentH2]; + } + + if ($heading['level'] === 3) { + if ($currentH2 === 0) { + $currentH2 = 1; + } + + $currentH3++; + + return [...$heading, 'number' => "{$currentH2}.{$currentH3}"]; + } + + return [...$heading, 'number' => '']; + }) + ->all(); + } + + /** + * @param array $headings + */ + private function addHeadingIds(string $html, array $headings): string + { + $levels = $this->tocLevels(); + + if ($headings === [] || $levels === []) { + return $html; + } + + $levelPattern = implode('', $levels); + $headingIndex = 0; + + return (string) preg_replace_callback( + "/(.*?)<\/h\\1>/s", + function (array $match) use ($headings, &$headingIndex): string { + $heading = $headings[$headingIndex] ?? null; + $headingIndex++; + + if ($heading === null) { + return $match[0]; + } + + return sprintf( + '%s', + (int) $match[1], + e($heading['id']), + $match[2], + (int) $match[1], + ); + }, + $html, + ); + } + + /** + * @return array + */ + private function navigation(string $activeSlug): array + { + $pages = config('documentation.pages', []); + + if (! is_array($pages)) { + return []; + } + + return collect($pages) + ->map(fn (array $page, string $slug): array => [ + 'slug' => $slug, + 'title' => (string) $page['title'], + 'url' => route('documentation.show', ['slug' => $slug], false), + 'active' => $slug === $activeSlug, + ]) + ->values() + ->all(); + } +} diff --git a/config/documentation.php b/config/documentation.php new file mode 100644 index 00000000..7134bd24 --- /dev/null +++ b/config/documentation.php @@ -0,0 +1,18 @@ + 'categories', + + 'toc' => [ + 'placeholder' => '{{TOC}}', + 'levels' => [2, 3], + ], + + 'pages' => [ + 'categories' => [ + 'title' => 'Categories', + 'description' => 'Learn how categories work in Whisper Money.', + 'file' => resource_path('docs/documentation/categories.md'), + ], + ], +]; diff --git a/resources/docs/documentation/categories.md b/resources/docs/documentation/categories.md new file mode 100644 index 00000000..3385eb43 --- /dev/null +++ b/resources/docs/documentation/categories.md @@ -0,0 +1,76 @@ +# Categories + +Categories explain what each transaction means. They make reports, budgets, and cashflow charts useful. + +{{TOC}} + +## What categories do + +Every transaction can have one category. Whisper Money uses that category to decide where the money appears in your summaries. + +For example: + +- Groceries, restaurants, and subscriptions usually count as expenses. +- Salary, refunds, and interest usually count as income. +- Savings and investment categories show money you are setting aside. +- Transfers help separate money moving between your own accounts from real spending or income. + +## Category types + +Categories have a type. The type controls how transactions are treated in reports. + +### Expense + +Use expense categories for money leaving your finances. Examples: groceries, rent, transport, subscriptions, and taxes. + +Expense categories appear in spending breakdowns and budget tracking. + +### Income + +Use income categories for money entering your finances. Examples: salary, freelance income, refunds, dividends, and interest. + +Income categories appear in cashflow reports and income summaries. + +### Transfer + +Use transfer categories when money moves between accounts you own. Examples: moving cash from checking to savings, paying a credit card from a bank account, or moving money to an investment account. + +Transfers should not be counted as normal income or spending. Categorizing them correctly keeps cashflow accurate. + +Transfer categories also have a cashflow direction: + +- Do not show: hides the transfer from cashflow. +- Show as cash inflow: shows the transfer as money coming in. +- Show as cash outflow: shows the transfer as money going out. + +### Savings + +Use savings categories when money is intentionally set aside. Examples: emergency fund, house deposit, vacation fund, or other goals. + +Savings categories help separate planned saving from everyday spending. + +### Investment + +Use investment categories when money goes into assets or investment accounts. Examples: brokerage deposits, index funds, retirement contributions, or crypto purchases. + +Investment categories help separate long-term wealth building from normal expenses. + +## Uncategorized transactions + +New imported transactions may start without a category. Review uncategorized transactions regularly so reports stay useful. + +If many similar transactions need the same category, create an automation rule so Whisper Money can categorize future transactions automatically. + +## Changing a category + +Changing a transaction category updates reports that include that transaction. This can affect spending totals, budget progress, income totals, savings totals, investment totals, and cashflow. + +Changing the category itself, such as its name or type, affects all transactions using that category. + +## Good category habits + +- Keep category names simple. +- Avoid creating several categories for the same kind of spending. +- Use transfer categories for account-to-account movement. +- Review uncategorized transactions before trusting monthly reports. +- Use automation rules for repeated merchants or descriptions. diff --git a/resources/js/pages/documentation/show.tsx b/resources/js/pages/documentation/show.tsx new file mode 100644 index 00000000..04c8ac7a --- /dev/null +++ b/resources/js/pages/documentation/show.tsx @@ -0,0 +1,93 @@ +import { type SharedData } from '@/types'; +import { __ } from '@/utils/i18n'; +import { Head, Link, usePage } from '@inertiajs/react'; + +type DocumentationDocument = { + slug: string; + title: string; + description: string; + html: string; +}; + +type NavigationItem = { + slug: string; + title: string; + url: string; + active: boolean; +}; + +type DocumentationShowProps = { + document: DocumentationDocument; + navigation: NavigationItem[]; +}; + +export default function DocumentationShow({ + document, + navigation, +}: DocumentationShowProps) { + const { appUrl } = usePage().props; + + return ( + <> + + + + + + + + + + +
    +
    + + +
    +
    +
    +
    +
    + + ); +} diff --git a/routes/web.php b/routes/web.php index 27641c48..b1f18f54 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AccountController; use App\Http\Controllers\BudgetController; use App\Http\Controllers\CashflowController; use App\Http\Controllers\DashboardController; +use App\Http\Controllers\DocumentationController; use App\Http\Controllers\LoanDetailController; use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OpenBanking\AccountMappingController; @@ -87,6 +88,9 @@ Route::get('terms', function () { return Inertia::render('terms'); })->name('terms'); +Route::get('documentation', DocumentationController::class)->name('documentation.index'); +Route::get('documentation/{slug}', DocumentationController::class)->name('documentation.show'); + Route::middleware(['auth', 'verified'])->group(function () { Route::get('subscribe', [SubscriptionController::class, 'index'])->name('subscribe'); Route::get('subscribe/checkout', [SubscriptionController::class, 'checkout'])->name('subscribe.checkout'); diff --git a/tests/Feature/DocumentationTest.php b/tests/Feature/DocumentationTest.php new file mode 100644 index 00000000..bab1a1a4 --- /dev/null +++ b/tests/Feature/DocumentationTest.php @@ -0,0 +1,74 @@ +get(route('documentation.index')) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('documentation/show') + ->where('document.slug', 'categories') + ->where('document.title', 'Categories') + ->where('document.description', 'Learn how categories work in Whisper Money.') + ->where('navigation.0.active', true) + ); +}); + +it('shows the categories documentation page', function () { + $this->get(route('documentation.show', ['slug' => 'categories'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('documentation/show') + ->where('document.slug', 'categories') + ->where('document.title', 'Categories') + ->where('navigation.0.url', '/documentation/categories') + ); +}); + +it('replaces the table of contents placeholder with heading links', function () { + $this->get(route('documentation.show', ['slug' => 'categories'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->where('document.html', fn (string $html): bool => ! str_contains($html, '{{TOC}}') + && str_contains($html, '