Add cards
This commit is contained in:
parent
4eb15d3de6
commit
47927ddb4d
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -13,17 +14,20 @@ class DocumentationController extends Controller
|
|||
public function __invoke(?string $slug = null): Response
|
||||
{
|
||||
$slug ??= $this->defaultSlug();
|
||||
$page = $this->page($slug);
|
||||
$locale = $this->locale();
|
||||
$page = $this->page($slug, $locale);
|
||||
$markdown = File::get($page['file']);
|
||||
|
||||
return Inertia::render('documentation/show', [
|
||||
'document' => [
|
||||
'slug' => $slug,
|
||||
'locale' => $locale,
|
||||
'title' => $page['title'],
|
||||
'description' => $page['description'],
|
||||
'html' => $this->html($markdown),
|
||||
'html' => $this->html($markdown, $locale),
|
||||
],
|
||||
'navigation' => $this->navigation($slug),
|
||||
'navigation' => $this->navigation($slug, $locale),
|
||||
'languages' => $this->languageLinks($slug, $locale),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -38,10 +42,44 @@ class DocumentationController extends Controller
|
|||
return $slug;
|
||||
}
|
||||
|
||||
private function locale(): string
|
||||
{
|
||||
$locale = App::currentLocale();
|
||||
|
||||
if (array_key_exists($locale, $this->supportedLocales())) {
|
||||
return $locale;
|
||||
}
|
||||
|
||||
return $this->fallbackLocale();
|
||||
}
|
||||
|
||||
private function fallbackLocale(): string
|
||||
{
|
||||
$locale = config('documentation.fallback_locale', 'en');
|
||||
|
||||
return is_string($locale) && $locale !== '' ? $locale : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function supportedLocales(): array
|
||||
{
|
||||
$locales = config('documentation.locales', []);
|
||||
|
||||
if (! is_array($locales)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($locales)
|
||||
->mapWithKeys(fn (mixed $label, string $locale): array => [$locale => (string) $label])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, description: string, file: string}
|
||||
*/
|
||||
private function page(string $slug): array
|
||||
private function page(string $slug, string $locale): array
|
||||
{
|
||||
$page = config("documentation.pages.{$slug}");
|
||||
|
||||
|
|
@ -49,30 +87,151 @@ class DocumentationController extends Controller
|
|||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! File::exists($page['file'])) {
|
||||
$file = $this->localizedValue($page['file'], $locale);
|
||||
|
||||
if (! File::exists($file)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => (string) $page['title'],
|
||||
'description' => (string) $page['description'],
|
||||
'file' => (string) $page['file'],
|
||||
'title' => $this->localizedValue($page['title'], $locale),
|
||||
'description' => $this->localizedValue($page['description'], $locale),
|
||||
'file' => $file,
|
||||
];
|
||||
}
|
||||
|
||||
private function html(string $markdown): string
|
||||
private function localizedValue(mixed $value, string $locale): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$fallbackLocale = $this->fallbackLocale();
|
||||
$localized = $value[$locale] ?? $value[$fallbackLocale] ?? null;
|
||||
|
||||
if (is_string($localized)) {
|
||||
return $localized;
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function html(string $markdown, string $locale): string
|
||||
{
|
||||
$cardBlocks = $this->extractCardBlocks($markdown);
|
||||
$headings = $this->headings($markdown);
|
||||
$html = (string) Str::of($markdown)->markdown([
|
||||
$html = (string) Str::of($cardBlocks['markdown'])->markdown([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$html = $this->replaceTocPlaceholder($html, $headings);
|
||||
$html = $this->replaceTocPlaceholder($html, $headings, $locale);
|
||||
$html = $this->replaceCardPlaceholders($html, $cardBlocks['html']);
|
||||
|
||||
return $this->addHeadingIds($html, $headings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{markdown: string, html: array<string, string>}
|
||||
*/
|
||||
private function extractCardBlocks(string $markdown): array
|
||||
{
|
||||
$cardBlocks = [];
|
||||
$output = [];
|
||||
$lines = preg_split('/\R/', $markdown) ?: [];
|
||||
$index = 0;
|
||||
$insideWrapper = false;
|
||||
$insideCard = false;
|
||||
$wrapperCards = [];
|
||||
$cardLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (! $insideWrapper && trim($line) === '<div class="cards-wrapper">') {
|
||||
$insideWrapper = true;
|
||||
$wrapperCards = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $insideWrapper) {
|
||||
$output[] = $line;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $insideCard && trim($line) === '<div class="card">') {
|
||||
$insideCard = true;
|
||||
$cardLines = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim($line) === '</div>') {
|
||||
if ($insideCard) {
|
||||
$insideCard = false;
|
||||
$wrapperCards[] = trim(implode("\n", $cardLines));
|
||||
$cardLines = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$placeholder = "DOCUMENTATION_CARDS_{$index}";
|
||||
$cardBlocks[$placeholder] = $this->cardsHtml($wrapperCards);
|
||||
$output[] = $placeholder;
|
||||
$insideWrapper = false;
|
||||
$wrapperCards = [];
|
||||
$index++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($insideCard) {
|
||||
$cardLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'markdown' => implode("\n", $output),
|
||||
'html' => $cardBlocks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $cards
|
||||
*/
|
||||
private function cardsHtml(array $cards): string
|
||||
{
|
||||
$items = collect($cards)
|
||||
->filter()
|
||||
->map(fn (string $card): string => '<section class="card">'.(string) Str::of($card)->markdown([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]).'</section>')
|
||||
->implode('');
|
||||
|
||||
if ($items === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="cards-wrapper">'.$items.'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $cardBlocks
|
||||
*/
|
||||
private function replaceCardPlaceholders(string $html, array $cardBlocks): string
|
||||
{
|
||||
foreach ($cardBlocks as $placeholder => $cardHtml) {
|
||||
$html = str_replace(["<p>{$placeholder}</p>", $placeholder], $cardHtml, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{level: int, title: string, id: string}>
|
||||
*/
|
||||
|
|
@ -118,6 +277,7 @@ class DocumentationController extends Controller
|
|||
->map(fn (mixed $level): int => (int) $level)
|
||||
->filter(fn (int $level): bool => $level >= 1 && $level <= 6)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
|
@ -155,7 +315,7 @@ class DocumentationController extends Controller
|
|||
/**
|
||||
* @param array<int, array{level: int, title: string, id: string}> $headings
|
||||
*/
|
||||
private function replaceTocPlaceholder(string $html, array $headings): string
|
||||
private function replaceTocPlaceholder(string $html, array $headings, string $locale): string
|
||||
{
|
||||
$placeholder = config('documentation.toc.placeholder', '{{TOC}}');
|
||||
|
||||
|
|
@ -165,7 +325,7 @@ class DocumentationController extends Controller
|
|||
|
||||
return str_replace(
|
||||
["<p>{$placeholder}</p>", $placeholder],
|
||||
$this->tocHtml($headings),
|
||||
$this->tocHtml($headings, $locale),
|
||||
$html,
|
||||
);
|
||||
}
|
||||
|
|
@ -173,7 +333,7 @@ class DocumentationController extends Controller
|
|||
/**
|
||||
* @param array<int, array{level: int, title: string, id: string}> $headings
|
||||
*/
|
||||
private function tocHtml(array $headings): string
|
||||
private function tocHtml(array $headings, string $locale): string
|
||||
{
|
||||
if ($headings === []) {
|
||||
return '';
|
||||
|
|
@ -189,7 +349,12 @@ class DocumentationController extends Controller
|
|||
))
|
||||
->implode('');
|
||||
|
||||
return '<nav class="documentation-toc" aria-label="Table of contents"><p>On this page</p><ol>'.$items.'</ol></nav>';
|
||||
return '<nav class="documentation-toc" aria-label="Table of contents"><p>'.$this->tocTitle($locale).'</p><ol>'.$items.'</ol></nav>';
|
||||
}
|
||||
|
||||
private function tocTitle(string $locale): string
|
||||
{
|
||||
return e($this->localizedValue(config('documentation.toc.title', 'On this page'), $locale));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -198,29 +363,31 @@ class DocumentationController extends Controller
|
|||
*/
|
||||
private function numberedHeadings(array $headings): array
|
||||
{
|
||||
$currentH2 = 0;
|
||||
$currentH3 = 0;
|
||||
$levels = $this->tocLevels();
|
||||
$counts = [];
|
||||
|
||||
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;
|
||||
->map(function (array $heading) use ($levels, &$counts): array {
|
||||
foreach ($levels as $level) {
|
||||
if ($level > $heading['level']) {
|
||||
unset($counts[$level]);
|
||||
}
|
||||
|
||||
$currentH3++;
|
||||
|
||||
return [...$heading, 'number' => "{$currentH2}.{$currentH3}"];
|
||||
}
|
||||
|
||||
return [...$heading, 'number' => ''];
|
||||
foreach ($levels as $level) {
|
||||
if ($level < $heading['level'] && ! isset($counts[$level])) {
|
||||
$counts[$level] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$counts[$heading['level']] = ($counts[$heading['level']] ?? 0) + 1;
|
||||
|
||||
$number = collect($levels)
|
||||
->filter(fn (int $level): bool => $level <= $heading['level'] && isset($counts[$level]))
|
||||
->map(fn (int $level): int => $counts[$level])
|
||||
->implode('.');
|
||||
|
||||
return [...$heading, 'number' => $number];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
|
@ -264,7 +431,7 @@ class DocumentationController extends Controller
|
|||
/**
|
||||
* @return array<int, array{slug: string, title: string, url: string, active: bool}>
|
||||
*/
|
||||
private function navigation(string $activeSlug): array
|
||||
private function navigation(string $activeSlug, string $locale): array
|
||||
{
|
||||
$pages = config('documentation.pages', []);
|
||||
|
||||
|
|
@ -275,11 +442,27 @@ class DocumentationController extends Controller
|
|||
return collect($pages)
|
||||
->map(fn (array $page, string $slug): array => [
|
||||
'slug' => $slug,
|
||||
'title' => (string) $page['title'],
|
||||
'url' => route('documentation.show', ['slug' => $slug], false),
|
||||
'title' => $this->localizedValue($page['title'] ?? '', $locale),
|
||||
'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false),
|
||||
'active' => $slug === $activeSlug,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{locale: string, label: string, url: string, active: bool}>
|
||||
*/
|
||||
private function languageLinks(string $slug, string $activeLocale): array
|
||||
{
|
||||
return collect($this->supportedLocales())
|
||||
->map(fn (string $label, string $locale): array => [
|
||||
'locale' => $locale,
|
||||
'label' => $label,
|
||||
'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false),
|
||||
'active' => $locale === $activeLocale,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,36 @@
|
|||
|
||||
return [
|
||||
'default' => 'categories',
|
||||
'fallback_locale' => 'en',
|
||||
|
||||
'locales' => [
|
||||
'en' => 'English',
|
||||
'es' => 'Español',
|
||||
],
|
||||
|
||||
'toc' => [
|
||||
'placeholder' => '{{TOC}}',
|
||||
'levels' => [2, 3],
|
||||
'title' => [
|
||||
'en' => 'On this page',
|
||||
'es' => 'En esta página',
|
||||
],
|
||||
],
|
||||
|
||||
'pages' => [
|
||||
'categories' => [
|
||||
'title' => 'Categories',
|
||||
'description' => 'Learn how categories work in Whisper Money.',
|
||||
'file' => resource_path('docs/documentation/categories.md'),
|
||||
'title' => [
|
||||
'en' => 'Categories',
|
||||
'es' => 'Categorías',
|
||||
],
|
||||
'description' => [
|
||||
'en' => 'Learn how categories work in Whisper Money.',
|
||||
'es' => 'Aprende cómo funcionan las categorías en Whisper Money.',
|
||||
],
|
||||
'file' => [
|
||||
'en' => resource_path('docs/documentation/en/categories.md'),
|
||||
'es' => resource_path('docs/documentation/es/categories.md'),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Categories
|
||||
|
||||
Categories help Whisper Money understand each transaction. Pick the right category, and your reports become easier to trust.
|
||||
|
||||
{{TOC}}
|
||||
|
||||
## Quick start
|
||||
|
||||
If you only read one section, read this one.
|
||||
|
||||
1. **Choose what the transaction is.** Is it spending, income, saving, investing, or a transfer?
|
||||
2. **Use transfers for money moving between your own accounts.** This keeps reports from counting the same money twice.
|
||||
3. **Review uncategorized transactions often.** Reports are only useful when transactions have the right category.
|
||||
4. **Create automation rules for repeated transactions.** Let Whisper Money handle future matches for you.
|
||||
|
||||
> Not sure what to pick? Start with the category type. The name can be adjusted later.
|
||||
|
||||
## Category map
|
||||
|
||||
Here is the basic idea:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
transaction[Transaction] --> category[Category]
|
||||
category --> reports[Reports]
|
||||
category --> budgets[Budgets]
|
||||
category --> cashflow[Cashflow]
|
||||
```
|
||||
|
||||
A few examples:
|
||||
|
||||
- 🛒 **Groceries** → Expense → spending report and budgets.
|
||||
- 💼 **Salary** → Income → income and cashflow reports.
|
||||
- 🏦 **Checking to savings** → Transfer or Savings → cashflow stays accurate.
|
||||
- 📈 **Broker deposit** → Investment → investing is separated from daily spending.
|
||||
|
||||
## What categories do
|
||||
|
||||
Every transaction can have one category.
|
||||
|
||||
Whisper Money uses that category to answer questions like:
|
||||
|
||||
- How much did I spend on food?
|
||||
- How much income came in this month?
|
||||
- Am I saving or investing regularly?
|
||||
- Is this real spending, or did I move money between my own accounts?
|
||||
|
||||
## Category types
|
||||
|
||||
Each category has a type. The type tells Whisper Money how to treat the transaction.
|
||||
|
||||
<div class="cards-wrapper">
|
||||
|
||||
<div class="card">
|
||||
### Expense
|
||||
|
||||
Money leaving your finances.
|
||||
|
||||
Examples:
|
||||
|
||||
- Groceries
|
||||
- Rent
|
||||
- Transport
|
||||
- Subscriptions
|
||||
- Taxes
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Income
|
||||
|
||||
Money coming into your finances.
|
||||
|
||||
Examples:
|
||||
|
||||
- Salary
|
||||
- Freelance income
|
||||
- Refunds
|
||||
- Dividends
|
||||
- Interest
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Transfer
|
||||
|
||||
Money moving between accounts you own.
|
||||
|
||||
Examples:
|
||||
|
||||
- Checking to savings
|
||||
- Bank account to credit card
|
||||
- Bank account to investment account
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Savings
|
||||
|
||||
Money intentionally set aside.
|
||||
|
||||
Examples:
|
||||
|
||||
- Emergency fund
|
||||
- House deposit
|
||||
- Vacation fund
|
||||
- Other money goals
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Investment
|
||||
|
||||
Money going into assets or investment accounts.
|
||||
|
||||
Examples:
|
||||
|
||||
- Broker deposits
|
||||
- Index funds
|
||||
- Retirement contributions
|
||||
- Crypto purchases
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Transfers and cashflow direction
|
||||
|
||||
Transfers can also have a cashflow direction.
|
||||
|
||||
Choose the option that best matches how you want the transfer to appear:
|
||||
|
||||
- **Do not show**: hide the transfer from cashflow.
|
||||
- **Show as cash inflow**: show the transfer as money coming in.
|
||||
- **Show as cash outflow**: show the transfer as money going out.
|
||||
|
||||
For most account-to-account movement, **Do not show** is the safest choice.
|
||||
|
||||
## Uncategorized transactions
|
||||
|
||||
Imported transactions may start without a category.
|
||||
|
||||
Try this routine:
|
||||
|
||||
1. Open uncategorized transactions.
|
||||
2. Assign the obvious ones first.
|
||||
3. Leave confusing ones for later if needed.
|
||||
4. Create automation rules for repeated merchants or descriptions.
|
||||
|
||||
This keeps reports clean without turning categorization into a big chore.
|
||||
|
||||
## Changing a category
|
||||
|
||||
Changing a transaction category updates every report that includes that transaction.
|
||||
|
||||
This can change:
|
||||
|
||||
- Spending totals
|
||||
- Budget progress
|
||||
- Income totals
|
||||
- Savings totals
|
||||
- Investment totals
|
||||
- Cashflow
|
||||
|
||||
Changing the category itself, such as its name or type, affects all transactions using that category.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What if I choose the wrong category?
|
||||
|
||||
You can change it later. Reports update after the transaction is recategorized.
|
||||
|
||||
### Should credit card payments be expenses?
|
||||
|
||||
Usually no. If you already track the card purchases, the payment is money moving between your own accounts. Use a transfer category.
|
||||
|
||||
### How many categories should I create?
|
||||
|
||||
Start small. Too many categories make reports harder to read. Add more only when you need more detail.
|
||||
|
||||
### When should I create an automation rule?
|
||||
|
||||
Create one when the same merchant or description keeps getting the same category.
|
||||
|
||||
## Good category habits
|
||||
|
||||
- Keep names short and clear.
|
||||
- Avoid duplicate categories for the same kind of spending.
|
||||
- Use transfer categories for movement between your own accounts.
|
||||
- Review uncategorized transactions before trusting monthly reports.
|
||||
- Automate repeated merchants and descriptions.
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Categorías
|
||||
|
||||
Las categorías ayudan a Whisper Money a entender cada transacción. Elige bien la categoría y tus informes serán más fáciles de confiar.
|
||||
|
||||
{{TOC}}
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
Si solo lees una sección, lee esta.
|
||||
|
||||
1. **Elige qué es la transacción.** ¿Es gasto, ingreso, ahorro, inversión o transferencia?
|
||||
2. **Usa transferencias para dinero que se mueve entre tus propias cuentas.** Así evitas contar el mismo dinero dos veces.
|
||||
3. **Revisa las transacciones sin categoría a menudo.** Los informes solo son útiles cuando las transacciones tienen la categoría correcta.
|
||||
4. **Crea reglas de automatización para transacciones repetidas.** Deja que Whisper Money gestione futuras coincidencias.
|
||||
|
||||
> ¿No sabes qué elegir? Empieza por el tipo de categoría. El nombre se puede ajustar después.
|
||||
|
||||
## Mapa de categorías
|
||||
|
||||
La idea básica es esta:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
transaction[Transacción] --> category[Categoría]
|
||||
category --> reports[Informes]
|
||||
category --> budgets[Presupuestos]
|
||||
category --> cashflow[Flujo de efectivo]
|
||||
```
|
||||
|
||||
Algunos ejemplos:
|
||||
|
||||
- 🛒 **Supermercado** → Gasto → informe de gastos y presupuestos.
|
||||
- 💼 **Salario** → Ingreso → informes de ingresos y flujo de efectivo.
|
||||
- 🏦 **Cuenta corriente a ahorro** → Transferencia o Ahorro → el flujo de efectivo sigue siendo preciso.
|
||||
- 📈 **Depósito en broker** → Inversión → la inversión queda separada del gasto diario.
|
||||
|
||||
## Qué hacen las categorías
|
||||
|
||||
Cada transacción puede tener una categoría.
|
||||
|
||||
Whisper Money usa esa categoría para responder preguntas como:
|
||||
|
||||
- ¿Cuánto gasté en comida?
|
||||
- ¿Cuánto ingreso entró este mes?
|
||||
- ¿Estoy ahorrando o invirtiendo con regularidad?
|
||||
- ¿Esto es un gasto real o moví dinero entre mis propias cuentas?
|
||||
|
||||
## Tipos de categoría
|
||||
|
||||
Cada categoría tiene un tipo. El tipo le dice a Whisper Money cómo tratar la transacción.
|
||||
|
||||
<div class="cards-wrapper">
|
||||
|
||||
<div class="card">
|
||||
### Gasto
|
||||
|
||||
Dinero que sale de tus finanzas.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- Supermercado
|
||||
- Alquiler
|
||||
- Transporte
|
||||
- Suscripciones
|
||||
- Impuestos
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Ingreso
|
||||
|
||||
Dinero que entra en tus finanzas.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- Salario
|
||||
- Ingresos freelance
|
||||
- Reembolsos
|
||||
- Dividendos
|
||||
- Intereses
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Transferencia
|
||||
|
||||
Dinero que se mueve entre cuentas tuyas.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- Cuenta corriente a ahorro
|
||||
- Cuenta bancaria a tarjeta de crédito
|
||||
- Cuenta bancaria a inversión
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Ahorro
|
||||
|
||||
Dinero que apartas intencionadamente.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- Fondo de emergencia
|
||||
- Entrada para una casa
|
||||
- Fondo de vacaciones
|
||||
- Otros objetivos de dinero
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
### Inversión
|
||||
|
||||
Dinero que va a activos o cuentas de inversión.
|
||||
|
||||
Ejemplos:
|
||||
|
||||
- Depósitos en broker
|
||||
- Fondos indexados
|
||||
- Aportaciones para jubilación
|
||||
- Compras de cripto
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Transferencias y dirección de flujo de efectivo
|
||||
|
||||
Las transferencias también pueden tener una dirección de flujo de efectivo.
|
||||
|
||||
Elige la opción que mejor encaje con cómo quieres que aparezca la transferencia:
|
||||
|
||||
- **No mostrar**: oculta la transferencia del flujo de efectivo.
|
||||
- **Mostrar como entrada de efectivo**: muestra la transferencia como dinero que entra.
|
||||
- **Mostrar como salida de efectivo**: muestra la transferencia como dinero que sale.
|
||||
|
||||
Para la mayoría de movimientos entre tus propias cuentas, **No mostrar** es la opción más segura.
|
||||
|
||||
## Transacciones sin categoría
|
||||
|
||||
Las transacciones importadas pueden empezar sin categoría.
|
||||
|
||||
Prueba esta rutina:
|
||||
|
||||
1. Abre las transacciones sin categoría.
|
||||
2. Asigna primero las más obvias.
|
||||
3. Deja las confusas para más tarde si hace falta.
|
||||
4. Crea reglas de automatización para comercios o descripciones repetidas.
|
||||
|
||||
Así mantienes los informes limpios sin convertir la categorización en una tarea pesada.
|
||||
|
||||
## Cambiar una categoría
|
||||
|
||||
Cambiar la categoría de una transacción actualiza todos los informes que incluyen esa transacción.
|
||||
|
||||
Esto puede cambiar:
|
||||
|
||||
- Totales de gasto
|
||||
- Progreso de presupuestos
|
||||
- Totales de ingresos
|
||||
- Totales de ahorro
|
||||
- Totales de inversión
|
||||
- Flujo de efectivo
|
||||
|
||||
Cambiar la categoría en sí, como su nombre o tipo, afecta a todas las transacciones que usan esa categoría.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
### ¿Qué pasa si elijo la categoría equivocada?
|
||||
|
||||
Puedes cambiarla después. Los informes se actualizan cuando la transacción se recategoriza.
|
||||
|
||||
### ¿Los pagos de tarjeta de crédito deberían ser gastos?
|
||||
|
||||
Normalmente no. Si ya registras las compras de la tarjeta, el pago es dinero moviéndose entre tus propias cuentas. Usa una categoría de transferencia.
|
||||
|
||||
### ¿Cuántas categorías debería crear?
|
||||
|
||||
Empieza con pocas. Demasiadas categorías hacen que los informes sean más difíciles de leer. Añade más solo cuando necesites más detalle.
|
||||
|
||||
### ¿Cuándo debería crear una regla de automatización?
|
||||
|
||||
Crea una cuando el mismo comercio o descripción siempre acaba con la misma categoría.
|
||||
|
||||
## Buenos hábitos con categorías
|
||||
|
||||
- Usa nombres cortos y claros.
|
||||
- Evita categorías duplicadas para el mismo tipo de gasto.
|
||||
- Usa categorías de transferencia para movimientos entre tus propias cuentas.
|
||||
- Revisa las transacciones sin categoría antes de confiar en los informes mensuales.
|
||||
- Automatiza comercios y descripciones repetidas.
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { type SharedData } from '@/types';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import { ArrowUpIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type DocumentationDocument = {
|
||||
slug: string;
|
||||
locale: string;
|
||||
title: string;
|
||||
description: string;
|
||||
html: string;
|
||||
|
|
@ -16,14 +19,131 @@ type NavigationItem = {
|
|||
active: boolean;
|
||||
};
|
||||
|
||||
type LanguageLink = {
|
||||
locale: string;
|
||||
label: string;
|
||||
url: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type DocumentationShowProps = {
|
||||
document: DocumentationDocument;
|
||||
navigation: NavigationItem[];
|
||||
languages: LanguageLink[];
|
||||
};
|
||||
|
||||
type MermaidModule = {
|
||||
default: {
|
||||
initialize: (options: { startOnLoad: boolean; theme: string }) => void;
|
||||
render: (id: string, definition: string) => Promise<{ svg: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
function MermaidDocumentationArticle({ html }: { html: string }) {
|
||||
const articleRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const article = articleRef.current;
|
||||
|
||||
if (!article) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const link = (event.target as HTMLElement | null)?.closest(
|
||||
'a[href^="#"]',
|
||||
);
|
||||
const hash = link?.getAttribute('href');
|
||||
|
||||
if (!hash || hash === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash.slice(1));
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
history.pushState(null, '', hash);
|
||||
};
|
||||
|
||||
article.addEventListener('click', handleClick);
|
||||
|
||||
return () => article.removeEventListener('click', handleClick);
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function renderMermaidDiagrams() {
|
||||
const article = articleRef.current;
|
||||
|
||||
if (!article) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = Array.from(
|
||||
article.querySelectorAll<HTMLElement>('code.language-mermaid'),
|
||||
);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: mermaid } = (await import(
|
||||
/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'
|
||||
)) as MermaidModule;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'default',
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
blocks.map(async (block, index) => {
|
||||
const container = document.createElement('div');
|
||||
const source = block.textContent ?? '';
|
||||
const { svg } = await mermaid.render(
|
||||
`documentation-mermaid-${index}-${crypto.randomUUID()}`,
|
||||
source,
|
||||
);
|
||||
|
||||
container.className = 'documentation-mermaid';
|
||||
container.innerHTML = svg;
|
||||
block.closest('pre')?.replaceWith(container);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void renderMermaidDiagrams();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={articleRef}
|
||||
className="max-w-3xl [&_.card]:rounded-xl [&_.card]:border [&_.card]:border-black/10 [&_.card]:bg-black/[0.02] [&_.card]:p-5 dark:[&_.card]:border-white/10 dark:[&_.card]:bg-white/[0.03] [&_.card_h3]:mt-0 [&_.card_h3]:mb-3 [&_.card_p]:mb-4 [&_.card_ul]:mb-0 [&_.cards-wrapper]:my-8 [&_.cards-wrapper]:grid [&_.cards-wrapper]:grid-cols-1 [&_.cards-wrapper]:gap-4 md:[&_.cards-wrapper]:grid-cols-2 [&_.documentation-mermaid]:my-6 [&_.documentation-mermaid]:overflow-x-auto [&_.documentation-mermaid]:p-5 [&_.documentation-toc]:my-8 [&_.documentation-toc]:rounded-xl [&_.documentation-toc]:border [&_.documentation-toc]:border-black/10 [&_.documentation-toc]:bg-black/[0.02] [&_.documentation-toc]:p-5 dark:[&_.documentation-toc]:border-white/10 dark:[&_.documentation-toc]:bg-white/[0.03] [&_.documentation-toc_.toc-level-3]:pl-4 [&_.documentation-toc_.toc-number]:text-[#706f6c] dark:[&_.documentation-toc_.toc-number]:text-[#A1A09A] [&_.documentation-toc_a]:no-underline [&_.documentation-toc_ol]:m-0 [&_.documentation-toc_ol]:list-none [&_.documentation-toc_ol]:space-y-2 [&_.documentation-toc_ol]:p-0 [&_.documentation-toc_p]:mb-3 [&_.documentation-toc_p]:text-sm [&_.documentation-toc_p]:font-semibold [&_a]:font-medium [&_a]:text-[#1b1b18] [&_a]:underline dark:[&_a]:text-[#EDEDEC] [&_blockquote]:my-6 [&_blockquote]:rounded-xl [&_blockquote]:border-l-4 [&_blockquote]:border-[#1b1b18] [&_blockquote]:bg-black/[0.03] [&_blockquote]:px-5 [&_blockquote]:py-4 dark:[&_blockquote]:border-[#EDEDEC] dark:[&_blockquote]:bg-white/[0.04] [&_blockquote_p]:mb-0 [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-1.5 [&_code]:py-0.5 dark:[&_code]:bg-white/10 [&_h1]:mb-5 [&_h1]:text-4xl [&_h1]:leading-tight [&_h1]:font-semibold [&_h2]:mt-12 [&_h2]:mb-4 [&_h2]:border-t [&_h2]:border-black/10 [&_h2]:pt-8 [&_h2]:text-2xl [&_h2]:font-semibold dark:[&_h2]:border-white/10 [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_li]:pl-1 [&_ol]:mb-5 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:mb-5 [&_p]:leading-7 [&_p]:text-[#706f6c] dark:[&_p]:text-[#A1A09A] [&_pre]:my-6 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-black/10 [&_pre]:bg-black/[0.03] [&_pre]:p-5 [&_pre]:text-sm dark:[&_pre]:border-white/10 dark:[&_pre]:bg-white/[0.04] [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:mb-5 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentationShow({
|
||||
document,
|
||||
navigation,
|
||||
languages,
|
||||
}: DocumentationShowProps) {
|
||||
const { appUrl } = usePage<SharedData>().props;
|
||||
|
||||
|
|
@ -33,7 +153,7 @@ export default function DocumentationShow({
|
|||
<meta name="description" content={__(document.description)} />
|
||||
<link
|
||||
rel="canonical"
|
||||
href={`${appUrl}/documentation/${document.slug}`}
|
||||
href={`${appUrl}/documentation/${document.slug}?lang=${document.locale}`}
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:title" content={__(document.title)} />
|
||||
|
|
@ -44,7 +164,7 @@ export default function DocumentationShow({
|
|||
<meta property="og:type" content="article" />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${appUrl}/documentation/${document.slug}`}
|
||||
content={`${appUrl}/documentation/${document.slug}?lang=${document.locale}`}
|
||||
/>
|
||||
</Head>
|
||||
|
||||
|
|
@ -58,6 +178,25 @@ export default function DocumentationShow({
|
|||
{__('\u2190 Back to home')}
|
||||
</Link>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-2">
|
||||
{languages.map((language) => (
|
||||
<Link
|
||||
key={language.locale}
|
||||
href={language.url}
|
||||
className={
|
||||
language.active
|
||||
? 'rounded-full bg-[#1b1b18] px-3 py-1 text-xs font-medium text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||||
: 'rounded-full border border-black/10 px-3 py-1 text-xs font-medium text-[#706f6c] hover:text-[#1b1b18] dark:border-white/10 dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]'
|
||||
}
|
||||
aria-current={
|
||||
language.active ? 'true' : undefined
|
||||
}
|
||||
>
|
||||
{language.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<nav aria-label={__('Documentation')}>
|
||||
<p className="mb-3 text-xs font-semibold tracking-[0.2em] text-[#706f6c] uppercase dark:text-[#A1A09A]">
|
||||
{__('Documentation')}
|
||||
|
|
@ -81,10 +220,7 @@ export default function DocumentationShow({
|
|||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<article
|
||||
className="max-w-3xl [&_.documentation-toc]:my-8 [&_.documentation-toc]:rounded-xl [&_.documentation-toc]:border [&_.documentation-toc]:border-black/10 [&_.documentation-toc]:p-5 dark:[&_.documentation-toc]:border-white/10 [&_.documentation-toc_.toc-level-3]:pl-4 [&_.documentation-toc_.toc-number]:text-[#706f6c] dark:[&_.documentation-toc_.toc-number]:text-[#A1A09A] [&_.documentation-toc_a]:no-underline [&_.documentation-toc_ol]:m-0 [&_.documentation-toc_ol]:list-none [&_.documentation-toc_ol]:space-y-2 [&_.documentation-toc_ol]:p-0 [&_.documentation-toc_p]:mb-3 [&_.documentation-toc_p]:text-sm [&_.documentation-toc_p]:font-semibold [&_a]:font-medium [&_a]:text-[#1b1b18] [&_a]:underline dark:[&_a]:text-[#EDEDEC] [&_h1]:mb-5 [&_h1]:text-4xl [&_h1]:leading-tight [&_h1]:font-semibold [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_li]:pl-1 [&_p]:mb-5 [&_p]:leading-7 [&_p]:text-[#706f6c] dark:[&_p]:text-[#A1A09A] [&_ul]:mb-5 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{ __html: document.html }}
|
||||
/>
|
||||
<MermaidDocumentationArticle html={document.html} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,26 +10,49 @@ it('shows the default documentation page', function () {
|
|||
fn (AssertableInertia $page) => $page
|
||||
->component('documentation/show')
|
||||
->where('document.slug', 'categories')
|
||||
->where('document.locale', 'en')
|
||||
->where('document.title', 'Categories')
|
||||
->where('document.description', 'Learn how categories work in Whisper Money.')
|
||||
->where('navigation.0.active', true)
|
||||
->where('languages.0.active', true)
|
||||
->where('languages.1.url', '/documentation/categories?lang=es')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the categories documentation page', function () {
|
||||
$this->get(route('documentation.show', ['slug' => 'categories']))
|
||||
it('shows the English categories documentation page', function () {
|
||||
$this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en']))
|
||||
->assertOk()
|
||||
->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->component('documentation/show')
|
||||
->where('document.slug', 'categories')
|
||||
->where('document.locale', 'en')
|
||||
->where('document.title', 'Categories')
|
||||
->where('navigation.0.url', '/documentation/categories')
|
||||
->where('navigation.0.url', '/documentation/categories?lang=en')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the Spanish categories documentation page', function () {
|
||||
$this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'es']))
|
||||
->assertOk()
|
||||
->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->component('documentation/show')
|
||||
->where('document.slug', 'categories')
|
||||
->where('document.locale', 'es')
|
||||
->where('document.title', 'Categorías')
|
||||
->where('document.description', 'Aprende cómo funcionan las categorías en Whisper Money.')
|
||||
->where('navigation.0.title', 'Categorías')
|
||||
->where('languages.0.url', '/documentation/categories?lang=en')
|
||||
->where('languages.1.active', true)
|
||||
->where('document.html', fn (string $html): bool => str_contains($html, 'Qué hacen las categorías')
|
||||
&& str_contains($html, 'href="#que-hacen-las-categorias"')
|
||||
&& str_contains($html, '<p>En esta página</p>'))
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces the table of contents placeholder with heading links', function () {
|
||||
$this->get(route('documentation.show', ['slug' => 'categories']))
|
||||
$this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en']))
|
||||
->assertOk()
|
||||
->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
|
|
@ -37,8 +60,13 @@ it('replaces the table of contents placeholder with heading links', function ()
|
|||
&& str_contains($html, '<nav class="documentation-toc"')
|
||||
&& str_contains($html, 'href="#what-categories-do"')
|
||||
&& str_contains($html, 'href="#expense"')
|
||||
&& str_contains($html, '<span class="toc-number">1</span> What categories do')
|
||||
&& str_contains($html, '<span class="toc-number">2.1</span> Expense')
|
||||
&& str_contains($html, 'language-mermaid')
|
||||
&& str_contains($html, 'flowchart TD')
|
||||
&& str_contains($html, '<div class="cards-wrapper">')
|
||||
&& str_contains($html, '<section class="card"><h3 id="expense">Expense</h3>')
|
||||
&& ! str_contains($html, '<div class="card">')
|
||||
&& str_contains($html, '<span class="toc-number">3</span> What categories do')
|
||||
&& str_contains($html, '<span class="toc-number">4.1</span> Expense')
|
||||
&& str_contains($html, '<h2 id="what-categories-do">')
|
||||
&& str_contains($html, '<h3 id="expense">')
|
||||
&& ! str_contains($html, 'href="#categories"'))
|
||||
|
|
@ -48,7 +76,7 @@ it('replaces the table of contents placeholder with heading links', function ()
|
|||
it('uses configured heading levels for the table of contents', function () {
|
||||
config(['documentation.toc.levels' => [2]]);
|
||||
|
||||
$this->get(route('documentation.show', ['slug' => 'categories']))
|
||||
$this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en']))
|
||||
->assertOk()
|
||||
->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
|
|
@ -63,12 +91,15 @@ it('returns not found for unknown documentation pages', function () {
|
|||
$this->get('/documentation/unknown')->assertNotFound();
|
||||
});
|
||||
|
||||
it('has markdown files for all configured documentation pages', function () {
|
||||
it('has markdown files for all configured documentation pages and locales', function () {
|
||||
$pages = config('documentation.pages');
|
||||
$locales = array_keys(config('documentation.locales'));
|
||||
|
||||
expect($pages)->toBeArray()->not->toBeEmpty();
|
||||
|
||||
foreach ($pages as $page) {
|
||||
expect(File::exists($page['file']))->toBeTrue();
|
||||
foreach ($locales as $locale) {
|
||||
expect(File::exists($page['file'][$locale]))->toBeTrue();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue