Compare commits

..

No commits in common. "main" and "v0.5.2" have entirely different histories.
main ... v0.5.2

42 changed files with 686 additions and 2394 deletions

View file

@ -67,11 +67,6 @@ jobs:
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true
ls -la release-assets/
- name: Copy changelogs to public
run: |
cp CHANGELOG.md public/CHANGELOG.md
cp CHANGELOG.fr.md public/CHANGELOG.fr.md
- name: Extract changelog
id: changelog
run: |

9
.gitignore vendored
View file

@ -23,13 +23,8 @@ imports/*.csv
# Environment
.env
.env.local
.env.*
*.local
# Secrets
*.pem
*.key
# IDE
.vscode/*
!.vscode/extensions.json
@ -45,9 +40,5 @@ imports/*.csv
.DS_Store
Thumbs.db
# Auto-generated changelogs (synced from root by vite.config.ts)
public/CHANGELOG.md
public/CHANGELOG.fr.md
# Tauri generated
src-tauri/gen/

View file

@ -1,266 +0,0 @@
# Journal des modifications
## [Non publié]
## [0.6.6]
### Modifié
- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34)
- Refactorisation de `buildPrevYearTotalMap` en inline et simplification des tests (#39)
### Corrigé
- Fichiers changelog synchronisés automatiquement via plugin Vite, copies obsolètes dans public/ supprimées (#37)
## [0.6.5]
### Ajouté
- Tableau de bord : menu déroulant de sélection du mois pour la section Budget vs Réel avec le dernier mois complété par défaut (#31)
### Modifié
- Rapports et tableau de bord : police réduite dans le menu déroulant de mois pour un meilleur équilibre visuel (#31)
## [0.6.4]
### Ajouté
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
### Corrigé
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
### Modifié
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
## [0.6.3]
### Ajouté
- Tableau de bord : histogramme empilé des dépenses par catégorie et par mois (#15)
- Tableau de bord : tableau budget vs réel du mois courant avec écart en $ et % (#15)
- Tableau de budget et rapport Budget vs Réel : formatage des sous-totaux de section avec poids visuel croissant (#14)
### Modifié
- Tableau de bord : période par défaut changée de « mois » à « année à ce jour » (#15)
- Tableau de bord : section des transactions récentes supprimée (#15)
- Tous les rapports tabulaires : les lignes de grand total utilisent maintenant une police plus grande (text-sm), gras et bordure supérieure plus épaisse pour une meilleure hiérarchie visuelle (#14)
### Corrigé
- Rapport catégories dans le temps : toutes les catégories sont maintenant affichées (limite passée de 8 à 50) (#13)
- Graphique par catégorie : les noms sur l'axe Y utilisent maintenant la couleur du texte principal au lieu du gris (#13)
- Graphique catégories dans le temps : le texte de la légende utilise maintenant la couleur du texte principal au lieu d'hériter la couleur de la catégorie (#13)
## [0.6.2]
### Ajouté
- Tableau de budget : sous-totaux par section (dépenses, revenus, transferts) affichés après chaque groupe (#11)
- Rapport Budget vs Réel : sous-totaux par section avec réel, prévu, écart ($) et écart (%) par type (#11)
### Corrigé
- Page catégories : le panneau de détail reste maintenant visible lors du défilement d'une longue liste de catégories (#12)
## [0.6.1]
### Ajouté
- Page historique des versions : historique complet accessible depuis les Paramètres à tout moment
- Changelog bilingue : les notes de version s'affichent dans la langue choisie par l'utilisateur (FR/EN)
### Corrigé
- Visibilité des labels de graphiques : les montants sur les barres empilées utilisent maintenant du texte noir avec contour blanc pour un meilleur contraste (#8)
- Tableau de budget : les cellules éditables affichent maintenant un fond au survol, un curseur pointeur et une info-bulle pour clarifier l'interaction (#9)
## [0.6.0]
### Ajouté
- Rapports : bascule entre vue tableau et graphique pour les onglets Tendances, Par catégorie et Évolution
- Rapports : option « Afficher les montants » pour afficher les valeurs directement sur les barres et courbes
- Rapports : panneau de filtres avec cases à cocher par catégorie (recherche, tout sélectionner/désélectionner) et filtre par source
- Rapports : le filtre source s'applique au niveau SQL pour des totaux filtrés précis
- Rapports : en-têtes de tableau fixes sur tous les tableaux de rapports (Rapport dynamique, Budget vs Réel)
- Rapports : survol interactif — barres non survolées estompées, info-bulle filtrée sur la catégorie survolée
- Rapports : le survol de la légende met en évidence la catégorie sur tous les mois (graphique Évolution)
### Corrigé
- Tableau des transactions : l'icône de commentaire devient orange (comme l'icône de ventilation) quand une note est présente (#7)
## [0.5.0]
### Ajouté
- Gestion d'erreurs : intercepte les plantages React et affiche une page d'erreur au lieu d'un écran blanc
- Délai de démarrage (10 s) sur la connexion à la base de données — affiche une page d'erreur au lieu d'un indicateur de chargement infini
- Page d'erreur avec « Rafraîchir », « Vérifier les mises à jour » et liens de contact
- Visionneuse de journaux dans les paramètres — capture la sortie console, filtrable par niveau, copiable, persiste entre les rafraîchissements
- Licence GPL-3.0 — le projet est maintenant open source
### Modifié
- Modale détail de rapport : colonnes triables — cliquez sur les en-têtes pour trier par date, description ou montant (#1)
- Modale détail de rapport : bascule pour afficher/masquer la colonne des montants (#3)
- Tableau de budget : les en-têtes de colonnes restent fixes lors du défilement vertical (#2)
### Corrigé
- Mise à jour automatique sur Linux : le champ version de `latest.json` n'a plus le préfixe `v`, téléversement au registre de paquets plus robuste
- Nouvelle tentative au démarrage : la connexion BD réessaie jusqu'à 3 fois avant d'afficher la page d'erreur (corrige l'échec au premier lancement sur Windows)
- Somme de contrôle de migration : répare automatiquement la somme de contrôle obsolète de la migration 1 au démarrage
## [0.4.4]
### Corrigé
- Le binaire Linux est maintenant compatible avec glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — le CI compile dans un conteneur Ubuntu 22.04
## [0.4.3]
### Corrigé
- Le point de terminaison de mise à jour automatique utilise maintenant le registre de paquets Forgejo pour une URL stable
- Les signatures Linux (.AppImage.sig) sont maintenant correctement collectées dans le CI
- Toutes les signatures de plateforme (.deb.sig, .rpm.sig) sont maintenant incluses dans les assets de la release
## [0.4.2]
### Modifié
- La mise à jour automatique pointe maintenant vers l'instance Forgejo auto-hébergée
- Les builds Windows sont maintenant compilés en croisé via cargo-xwin
## [0.4.1]
### Corrigé
- Application bloquée sur un indicateur de chargement infini après mise à jour depuis v0.3.x (somme de contrôle de migration incompatible sur seed_categories.sql)
- Les erreurs de connexion BD sont maintenant journalisées dans la console au lieu d'échouer silencieusement
## [0.4.0]
### Ajouté
- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)
- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »
- Budget : sous-totaux intermédiaires et indentation 3 niveaux pour les catégories imbriquées
- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories
- Catégories : la validation de profondeur empêche la création d'un 4e niveau
- Données initiales : Assurances divisées en Assurance-auto, Assurance-habitation, Assurance-vie
### Corrigé
- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus
- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot
## [0.3.11]
### Ajouté
- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)
### Corrigé
- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau
## [0.3.10]
### Ajouté
- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)
- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)
- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)
## [0.3.9]
### Ajouté
- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs
- Suppression de mots-clés depuis la vue « Tous les mots-clés »
## [0.3.8]
### Ajouté
- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord
- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail
- Affichage des notes de version du CHANGELOG dans les releases et le système de mise à jour
## [0.3.7]
### Corrigé
- Suppression du bundle MSI pour éviter le conflit de chemin d'installation du système de mise à jour
- Changement du mode d'installation Windows à basicUi
- Amélioration de la visibilité de l'indicateur de ventilation et de la mise en page des ajustements
## 0.3.2
### Nouvelles fonctionnalités
- **Support Linux** : ajout des builds Linux (`.deb`, `.rpm`, `.AppImage`) au workflow de release
- **Ventilations sur la page Ajustements** : visualisez les ajustements de ventilation des transactions dans une section dédiée
### Corrigé
- Correction de cas limites de détection automatique CSV
- Suppression de l'accent dans productName pour la compatibilité `.deb` Linux
## 0.3.1
### Corrigé
- Toujours afficher le sélecteur de profil dans la barre latérale (#2)
## 0.3.0
### Nouvelles fonctionnalités
- **Profils multiples** : créez plusieurs profils avec des bases de données séparées, des noms et couleurs personnalisés
- **Protection par NIP** : protégez les profils avec un NIP numérique optionnel
- **Sélecteur de profil** : changement rapide de profil depuis la barre latérale
- **Glisser-déposer les catégories** : réordonnez les catégories ou changez le parent par glisser-déposer dans l'arborescence
- **Ventilation des transactions** : ventilez une transaction sur plusieurs catégories avec des montants ajustables
## 0.2.10
### Nouvelles fonctionnalités
- **Sélection rapide de période** : boutons de filtre rapide (Ce mois, Mois dernier, etc.) sur la page Transactions
- **Rapport Budget vs Réel** : tableau comparatif mensuel et cumulatif annuel dans les Rapports
- **Sous-totaux de catégories parentes** : la page Budget affiche les sous-totaux agrégés pour les catégories parentes
- **Guide utilisateur** : documentation complète accessible depuis les Paramètres, imprimable en PDF
### Améliorations
- Persistance de la sélection de modèle et ajout du bouton Mettre à jour le modèle
- Ne plus pré-sélectionner les fichiers déjà importés à l'entrée de la configuration source
- Rendre les imports de données de paramètres visibles dans l'historique d'import
- Remplacer les boutons de suppression par modèle par un seul bouton sur la sélection
- Remplacer l'icône de rafraîchissement par une icône de sauvegarde sur le bouton de mise à jour du modèle
- Ajout de la convention de signe à la page budget
## 0.2.9
### Corrigé
- Permettre les fichiers à contenu identique avec des noms différents (#1)
## 0.2.8
### Nouvelles fonctionnalités
- **Export/import de données** : exportez et importez vos données (transactions, catégories, ou les deux) avec chiffrement AES-256-GCM optionnel (#3)
### Corrigé
- Détection de doublons inter-fichiers et suivi d'import par fichier
## 0.2.5
### Nouvelles fonctionnalités
- **Modèles de configuration d'import** : sauvegardez et chargez les configurations de source d'import comme modèles réutilisables
- **Grille de budget 12 mois** : vue budgétaire annuelle complète avec cellules mensuelles et totaux annuels
### Corrigé
- Corrections du budget et des catégories
- Problème de somme de contrôle de migration (schema.sql ne doit pas être modifié après la release initiale)
## 0.2.3
### Nouvelles fonctionnalités
- **Motifs de graphiques** : ajout de motifs de remplissage SVG (lignes diagonales, points, hachures, etc.) pour différencier les catégories dans les graphiques au-delà de la couleur
- **Menu contextuel des graphiques** : clic-droit sur une catégorie dans un graphique pour la masquer ou voir ses transactions dans une fenêtre de détail
- **Catégories masquées** : les catégories masquées apparaissent comme des puces au-dessus des graphiques avec un bouton « Tout afficher »
- **Modale de détail des transactions** : visualisez toutes les transactions composant le total d'une catégorie directement depuis n'importe quel graphique
- **Aperçu d'import en popup** : l'aperçu des données est maintenant une modale popup au lieu d'une étape séparée de l'assistant
- **Vérification directe des doublons** : nouveau bouton « Vérifier les doublons » sur la page de configuration d'import
### Améliorations
- Flux de l'assistant d'import simplifié : configuration source → vérification des doublons (l'aperçu est optionnel via popup)
- Le bouton retour de la vérification des doublons retourne maintenant à la configuration source
## 0.2.2
- Mise à jour de version
## 0.2.1
- Ajout de la vue « Tous les mots-clés » sur la page Catégories
- Ajout du mode sombre avec palette de gris chauds
- Correction des catégories orphelines, persistance de has_header pour les imports, ajout de la réinitialisation
- Ajout des pages Budget et Ajustements

View file

@ -2,89 +2,6 @@
## [Unreleased]
## [0.6.6]
### Changed
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
### Fixed
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
## [0.6.5]
### Added
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
### Changed
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
## [0.6.4]
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
### Changed
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
## [0.6.3]
### Added
- Dashboard: expenses over time stacked bar chart by category and month (#15)
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
### Changed
- Dashboard: default period changed from "month" to "year to date" (#15)
- Dashboard: removed recent transactions section (#15)
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
### Fixed
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
## [0.6.2]
### Added
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
### Fixed
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
## [0.6.1]
### Added
- Changelog page: full version history accessible from Settings at any time
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
### Fixed
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
## [0.6.0]
### Added
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
- Reports: source filter applies at SQL level for accurate filtered totals
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
- Reports: legend hover highlights category across all months (Over Time chart)
### Fixed
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
## [0.5.0]
### Added

View file

@ -9,7 +9,7 @@
**Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux
**Version actuelle :** 0.6.3
**Version actuelle :** 0.5.0
**Licence :** GPL-3.0-only
---
@ -50,7 +50,7 @@ src/
│ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 12 hooks custom (useReducer)
├── pages/ # 11 pages
├── pages/ # 10 pages
├── services/ # 14 services métier
├── shared/ # Types et constantes partagés
├── utils/ # Utilitaires (parsing, CSV, charts)
@ -92,7 +92,6 @@ src-tauri/
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
- **Changelog bilingue** : page `/changelog` avec historique complet, notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`)
---
@ -136,12 +135,9 @@ La documentation technique est centralisée dans `docs/` :
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
- Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`)
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans **les deux fichiers** :
- `CHANGELOG.md` (anglais) — source principale
- `CHANGELOG.fr.md` (français) — traduction
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur.
- The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed.
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans `CHANGELOG.md`
- Catégories : Added, Changed, Fixed, Removed
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes GitHub et affiché dans l'app.
---

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-03-07 — Version 0.6.3
> Document mis à jour le 2026-03-01 — Version 0.4.7
## Stack technique
@ -30,11 +30,11 @@ simpl-resultat/
│ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants
│ │ ├── dashboard/ # 3 composants
│ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants
@ -190,7 +190,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description |
|-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
| `/import` | `ImportPage` | Assistant d'import CSV |
| `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
@ -199,7 +199,6 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/reports` | `ReportsPage` | Analytique et rapports |
| `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).

View file

@ -71,9 +71,8 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Cartes résumées du solde, des revenus et des dépenses
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
- Tableau Budget vs Réel du mois courant (écart en $ et %)
- Histogramme empilé des dépenses par catégorie et par mois
- Sélecteur de période ajustable (par défaut : année à ce jour)
- Liste des transactions récentes
- Sélecteur de période ajustable
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
### Comment faire
@ -81,13 +80,11 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
4. Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions
5. Faites défiler vers le bas pour voir vos transactions les plus récentes
### Astuces
- La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs

577
package-lock.json generated
View file

@ -1,13 +1,12 @@
{
"name": "simpl_result_scaffold",
"version": "0.6.5",
"version": "0.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.6.5",
"license": "GPL-3.0-only",
"version": "0.3.7",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@ -35,8 +34,7 @@
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.3",
"vite": "^6.4.1",
"vitest": "^4.0.18"
"vite": "^6.4.1"
}
},
"node_modules/@babel/code-frame": {
@ -861,350 +859,325 @@
"dev": true
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@ -1764,17 +1737,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -1829,13 +1791,6 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1903,127 +1858,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -2086,16 +1920,6 @@
}
]
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2288,13 +2112,6 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
@ -2350,31 +2167,11 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2820,29 +2617,11 @@
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3052,11 +2831,10 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
@ -3068,31 +2846,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"fsevents": "~2.3.2"
}
},
@ -3115,13 +2893,6 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3131,20 +2902,6 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@ -3169,23 +2926,6 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3202,16 +2942,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3369,84 +3099,6 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -3455,23 +3107,6 @@
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -1,16 +1,14 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.6.6",
"version": "0.5.0",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
"tauri": "tauri"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -39,7 +37,6 @@
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.3",
"vite": "^6.4.1",
"vitest": "^4.0.18"
"vite": "^6.4.1"
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.6.6"
version = "0.4.4"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.6.6",
"version": "0.5.2",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -12,7 +12,6 @@ import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage";
import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage";
@ -103,7 +102,6 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
</Route>
</Routes>
</BrowserRouter>

View file

@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
@ -19,6 +18,58 @@ const MONTH_KEYS = [
const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
interface BudgetTableProps {
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
@ -133,16 +184,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
income: "budget.income",
transfer: "budget.transfers",
};
const typeTotalKeys: Record<string, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type);
@ -150,10 +195,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * sign;
}
annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
const totalCols = 15; // category + prev year + annual + 12 months
const totalCols = 14; // category + annual + 12 months
if (rows.length === 0) {
return (
@ -179,15 +223,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0;
const isIntermediateParent = parentDepth >= 1;
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
const isIntermediateParent = parentDepth === 1;
return (
<tr
key={rowKey}
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
>
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
<td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
@ -196,9 +238,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</div>
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.previousYearTotal)}
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)}
</td>
@ -218,7 +257,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
>
{/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
@ -227,12 +266,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className="truncate text-xs">{row.category_name}</span>
</div>
</td>
{/* Previous year total — read-only */}
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
<span className="text-xs px-1 py-0.5">
{formatSigned(row.previousYearTotal)}
</span>
</td>
{/* Annual total — editable */}
<td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? (
@ -250,8 +283,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
title={t("budget.clickToEdit")}
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
>
{formatSigned(row.annual * sign)}
</button>
@ -283,8 +315,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
) : (
<button
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
title={t("budget.clickToEdit")}
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
>
{formatSigned(val * sign)}
</button>
@ -313,9 +344,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
@ -330,18 +358,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{typeOrder.map((type) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
const sign = signFor(type);
const leaves = group.filter((r) => !r.is_parent);
const sectionMonthTotals: number[] = Array(12).fill(0);
let sectionAnnualTotal = 0;
let sectionPrevYearTotal = 0;
for (const row of leaves) {
for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign;
}
sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
return (
<Fragment key={type}>
<tr>
@ -353,28 +369,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</td>
</tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
</tr>
</Fragment>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
<tr className="bg-[var(--muted)] font-semibold">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm">
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
{formatSigned(total)}
</td>
))}

View file

@ -23,7 +23,6 @@ export default function CategoryPieChart({
}: CategoryPieChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [isChartHovered, setIsChartHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -37,14 +36,17 @@ export default function CategoryPieChart({
if (data.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
@ -67,12 +69,8 @@ export default function CategoryPieChart({
</div>
)}
<div
onContextMenu={handleContextMenu}
onMouseEnter={() => setIsChartHovered(true)}
onMouseLeave={() => setIsChartHovered(false)}
>
<ResponsiveContainer width="100%" height={180}>
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<ChartPatternDefs
prefix="cat-pie"
@ -84,8 +82,8 @@ export default function CategoryPieChart({
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={35}
outerRadius={75}
innerRadius={50}
outerRadius={100}
paddingAngle={2}
>
{visibleData.map((item, index) => (
@ -99,11 +97,9 @@ export default function CategoryPieChart({
))}
</Pie>
<Tooltip
formatter={(value) => {
const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value));
const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : "";
return `${formatted}${pct}`;
}}
formatter={(value) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
@ -117,14 +113,13 @@ export default function CategoryPieChart({
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name);
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
return (
<button
key={index}
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item });
@ -134,7 +129,7 @@ export default function CategoryPieChart({
>
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]">
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</button>
);

View file

@ -2,7 +2,6 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
@ -26,6 +25,55 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -57,11 +105,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
income: t("budget.income"),
transfer: t("budget.transfers"),
};
const typeTotalKeys: Record<SectionType, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
let currentType: SectionType | null = null;
for (const row of data) {
@ -99,91 +142,69 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
<thead>
<tr className="border-b border-[var(--border)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
{t("budget.category")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.monthly")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{sections.map((section) => {
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
const sectionTotals = sectionLeaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return (
{sections.map((section) => (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
<tr className="bg-[var(--muted)]/50">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
{section.label}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
const isIntermediateParent = isParent && depth === 1;
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`}
>
<td className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}>
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
@ -215,53 +236,29 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr>
);
})}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment>
);
})}
))}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td>
</tr>

View file

@ -8,7 +8,6 @@ import {
Tooltip,
ResponsiveContainer,
Cell,
LabelList,
} from "recharts";
import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types";
@ -24,7 +23,6 @@ interface CategoryBarChartProps {
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
}
export default function CategoryBarChart({
@ -33,11 +31,9 @@ export default function CategoryBarChart({
onToggleHidden,
onShowAll,
onViewDetails,
showAmounts,
}: CategoryBarChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -97,7 +93,7 @@ export default function CategoryBarChart({
type="category"
dataKey="category_name"
width={120}
tick={{ fill: "var(--foreground)", fontSize: 12 }}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<Tooltip
@ -107,9 +103,7 @@ export default function CategoryBarChart({
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
@ -118,21 +112,11 @@ export default function CategoryBarChart({
<Cell
key={index}
fill={getPatternFill("cat-bar", index, item.category_color)}
fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3}
onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }}
onMouseEnter={() => { hoveredRef.current = item; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }}
/>
))}
{showAmounts && (
<LabelList
dataKey="total"
position="right"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--foreground)", fontSize: 11 }}
/>
)}
</Bar>
</BarChart>
</ResponsiveContainer>

View file

@ -9,7 +9,6 @@ import {
ResponsiveContainer,
Legend,
CartesianGrid,
LabelList,
} from "recharts";
import { Eye } from "lucide-react";
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
@ -31,7 +30,6 @@ interface CategoryOverTimeChartProps {
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
}
export default function CategoryOverTimeChart({
@ -40,11 +38,9 @@ export default function CategoryOverTimeChart({
onToggleHidden,
onShowAll,
onViewDetails,
showAmounts,
}: CategoryOverTimeChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null);
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
@ -113,52 +109,28 @@ export default function CategoryOverTimeChart({
width={80}
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
if (hoveredCategory && name !== hoveredCategory) return [null, null];
return [cadFormatter(Number(value) || 0), String(name)];
}}
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
filterNull
/>
<Legend
onMouseEnter={(e) => {
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
}}
onMouseLeave={() => setHoveredCategory(null)}
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/>
<Legend />
{categoryEntries.map((c) => (
<Bar
key={c.name}
dataKey={c.name}
stackId="stack"
fill={getPatternFill("cat-time", c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
onMouseEnter={() => { hoveredRef.current = c.name; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }}
>
{showAmounts && (
<LabelList
dataKey={c.name}
position="center"
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
/>
)}
</Bar>
))}
</BarChart>
</ResponsiveContainer>

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
}
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
const { t } = useTranslation();
if (data.data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const visibleCategories = hiddenCategories?.size
? data.categories.filter((name) => !hiddenCategories.has(name))
: data.categories;
const months = data.data.map((d) => d.month);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
{t("budget.category")}
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{visibleCategories.map((category) => {
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
return (
<tr key={category} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: data.colors[category] }}
/>
{category}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return (
<td key={month} className="text-right px-3 py-1.5">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
{cadFormatter(rowTotal)}
</td>
</tr>
);
})}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const monthTotal = visibleCategories.reduce(
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
0,
);
return (
<td key={month} className="text-right px-3 py-3">
{cadFormatter(monthTotal)}
</td>
);
})}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
0,
),
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,74 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryBreakdownItem } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
interface CategoryTableProps {
data: CategoryBreakdownItem[];
hiddenCategories?: Set<string>;
}
export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) {
const { t } = useTranslation();
const visibleData = hiddenCategories?.size
? data.filter((d) => !hiddenCategories.has(d.category_name))
: data;
if (visibleData.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("common.total")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
%
</th>
</tr>
</thead>
<tbody>
{visibleData.map((row) => (
<tr key={row.category_id ?? "uncategorized"} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.total)}</td>
<td className="text-right px-3 py-1.5 text-[var(--muted-foreground)]">
{grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"}
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -149,18 +149,18 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<thead>
<tr className="border-b border-[var(--border)]">
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{fieldLabel(dim)}
</th>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th>
))
@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-2">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))

View file

@ -7,7 +7,6 @@ import {
Tooltip,
ResponsiveContainer,
CartesianGrid,
LabelList,
} from "recharts";
import type { MonthlyTrendItem } from "../../shared/types";
@ -22,10 +21,9 @@ function formatMonth(month: string): string {
interface MonthlyTrendsChartProps {
data: MonthlyTrendItem[];
showAmounts?: boolean;
}
export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) {
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
const { t } = useTranslation();
if (data.length === 0) {
@ -82,16 +80,7 @@ export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsC
stroke="var(--positive)"
fill="url(#gradientIncome)"
strokeWidth={2}
>
{showAmounts && (
<LabelList
dataKey="income"
position="top"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }}
/>
)}
</Area>
<Area
type="monotone"
dataKey="expenses"
@ -99,16 +88,7 @@ export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsC
stroke="var(--negative)"
fill="url(#gradientExpenses)"
strokeWidth={2}
>
{showAmounts && (
<LabelList
dataKey="expenses"
position="bottom"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }}
/>
)}
</Area>
</AreaChart>
</ResponsiveContainer>
</div>

View file

@ -1,77 +0,0 @@
import { useTranslation } from "react-i18next";
import type { MonthlyTrendItem } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
interface MonthlyTrendsTableProps {
data: MonthlyTrendItem[];
}
export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const totals = data.reduce(
(acc, row) => ({ income: acc.income + row.income, expenses: acc.expenses + row.expenses }),
{ income: 0, expenses: 0 },
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.pivot.month")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.expenses")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.net")}
</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.month} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5">{formatMonth(row.month)}</td>
<td className="text-right px-3 py-1.5 text-[var(--positive)]">{cadFormatter(row.income)}</td>
<td className="text-right px-3 py-1.5 text-[var(--negative)]">{cadFormatter(row.expenses)}</td>
<td className={`text-right px-3 py-1.5 ${row.income - row.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(row.income - row.expenses)}
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
<td className="text-right px-3 py-3 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
<td className={`text-right px-3 py-3 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(totals.income - totals.expenses)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,135 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
interface ReportFilterPanelProps {
categories: { name: string; color: string }[];
hiddenCategories: Set<string>;
onToggleHidden: (name: string) => void;
onShowAll: () => void;
sources: ImportSource[];
selectedSourceId: number | null;
onSourceChange: (id: number | null) => void;
}
export default function ReportFilterPanel({
categories,
hiddenCategories,
onToggleHidden,
onShowAll,
sources,
selectedSourceId,
onSourceChange,
}: ReportFilterPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState(false);
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
const allVisible = hiddenCategories.size === 0;
const allHidden = hiddenCategories.size === categories.length;
return (
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
{/* Source filter */}
{sources.length > 1 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("transactions.table.source")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={selectedSourceId ?? ""}
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("transactions.filters.allSources")}</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
)}
{/* Category filter */}
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("reports.filters.title")}
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{categories.length - hiddenCategories.size}/{categories.length}
</span>
</button>
{!collapsed && (
<div className="border-t border-[var(--border)]">
<div className="px-2 py-2">
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("reports.filters.search")}
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
</div>
<div className="px-2 pb-1 flex gap-1">
<button
onClick={onShowAll}
disabled={allVisible}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.all")}
</button>
<button
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
disabled={allHidden}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.none")}
</button>
</div>
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
{filtered.map((cat) => {
const visible = !hiddenCategories.has(cat.name);
return (
<label
key={cat.name}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleHidden(cat.name)}
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
{cat.name}
</span>
</label>
);
})}
</div>
</div>
)}
</div>}
</div>
);
}

View file

@ -196,7 +196,7 @@ export default function TransactionTable({
onClick={() => toggleNotes(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
row.notes
? "text-orange-500"
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.notes.placeholder")}

View file

@ -3,7 +3,6 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import {
getAllActiveCategories,
getBudgetEntriesForYear,
getActualTotalsForYear,
upsertBudgetEntry,
upsertBudgetEntriesForYear,
getAllTemplates,
@ -73,10 +72,9 @@ export function useBudget() {
dispatch({ type: "SET_ERROR", payload: null });
try {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
const [allCategories, entries, templates] = await Promise.all([
getAllActiveCategories(),
getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1),
getAllTemplates(),
]);
@ -89,13 +87,6 @@ export function useBudget() {
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
// Build a map for previous year actuals: categoryId -> annual actual total
// Amounts are already signed (expenses negative, income positive) — stored as-is.
const prevYearTotalMap = new Map<number, number>();
for (const a of prevYearActuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
// Helper: build months array from entryMap
const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId);
@ -106,8 +97,7 @@ export function useBudget() {
months.push(val);
annual += val;
}
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
return { months, annual, previousYearTotal };
return { months, annual };
};
// Index categories by id and group children by parent_id
@ -127,7 +117,7 @@ export function useBudget() {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2
const { months, annual, previousYearTotal } = buildMonths(cat.id);
const { months, annual } = buildMonths(cat.id);
return [{
category_id: cat.id,
category_name: cat.name,
@ -138,7 +128,6 @@ export function useBudget() {
depth: 2,
months,
annual,
previousYearTotal,
}];
}
if (grandchildren.length === 0 && !cat.is_inputable) {
@ -149,7 +138,7 @@ export function useBudget() {
const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
const { months, annual } = buildMonths(cat.id);
gcRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
@ -160,11 +149,10 @@ export function useBudget() {
depth: 2,
months,
annual,
previousYearTotal,
});
}
for (const gc of grandchildren) {
const { months, annual, previousYearTotal } = buildMonths(gc.id);
const { months, annual } = buildMonths(gc.id);
gcRows.push({
category_id: gc.id,
category_name: gc.name,
@ -175,7 +163,6 @@ export function useBudget() {
depth: 2,
months,
annual,
previousYearTotal,
});
}
if (gcRows.length === 0) return [];
@ -183,11 +170,9 @@ export function useBudget() {
// Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
}
const subtotal: BudgetYearRow = {
category_id: cat.id,
@ -199,7 +184,6 @@ export function useBudget() {
depth: 1,
months: subMonths,
annual: subAnnual,
previousYearTotal: subPrevYear,
};
gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1;
@ -219,7 +203,7 @@ export function useBudget() {
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row
const { months, annual, previousYearTotal } = buildMonths(cat.id);
const { months, annual } = buildMonths(cat.id);
rows.push({
category_id: cat.id,
category_name: cat.name,
@ -230,14 +214,13 @@ export function useBudget() {
depth: 0,
months,
annual,
previousYearTotal,
});
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
const { months, annual } = buildMonths(cat.id);
allChildRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
@ -248,7 +231,6 @@ export function useBudget() {
depth: 1,
months,
annual,
previousYearTotal,
});
}
@ -256,7 +238,7 @@ export function useBudget() {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) {
// Simple leaf at depth 1
const { months, annual, previousYearTotal } = buildMonths(child.id);
const { months, annual } = buildMonths(child.id);
allChildRows.push({
category_id: child.id,
category_name: child.name,
@ -267,7 +249,6 @@ export function useBudget() {
depth: 1,
months,
annual,
previousYearTotal,
});
} else {
// Intermediate parent at depth 1 with grandchildren
@ -286,11 +267,9 @@ export function useBudget() {
const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
}
rows.push({
@ -303,7 +282,6 @@ export function useBudget() {
depth: 0,
months: parentMonths,
annual: parentAnnual,
previousYearTotal: parentPrevYear,
});
// Sort children alphabetically, but keep "(direct)" first

View file

@ -3,25 +3,19 @@ import type {
DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
RecentTransaction,
} from "../shared/types";
import {
getDashboardSummary,
getExpensesByCategory,
getRecentTransactions,
} from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
recentTransactions: RecentTransaction[];
period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string;
customDateTo: string;
isLoading: boolean;
@ -36,27 +30,22 @@ type DashboardAction =
payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
recentTransactions: RecentTransaction[];
};
}
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`;
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetVsActual: [],
period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr,
recentTransactions: [],
period: "month",
customDateFrom: monthStartStr,
customDateTo: todayStr,
isLoading: false,
error: null,
@ -73,14 +62,11 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual,
recentTransactions: action.payload.recentTransactions,
isLoading: false,
};
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
default:
@ -88,32 +74,69 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
}
}
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useDashboard() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (
period: DashboardPeriod,
customFrom: string | undefined,
customTo: string | undefined,
bYear: number,
bMonth: number,
) => {
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo),
getCategoryOverTime(dateFrom, dateTo),
getBudgetVsActualData(bYear, bMonth),
getRecentTransactions(10),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
@ -124,8 +147,8 @@ export function useDashboard() {
}, []);
useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
fetchData(state.period, state.customDateFrom, state.customDateTo);
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +158,5 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
return { state, setPeriod, setCustomDates, setBudgetMonth };
return { state, setPeriod, setCustomDates };
}

View file

@ -12,14 +12,12 @@ import type {
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
sourceId: number | null;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
@ -44,8 +42,7 @@ type ReportsAction =
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_SOURCE_ID"; payload: number | null };
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@ -56,12 +53,11 @@ const initialState: ReportsState = {
period: "6months",
customDateFrom: monthStartStr,
customDateTo: todayStr,
sourceId: null,
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() + 1,
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
@ -95,13 +91,55 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
return { ...state, pivotResult: action.payload, isLoading: false };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
case "SET_SOURCE_ID":
return { ...state, sourceId: action.payload };
default:
return state;
}
}
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
@ -114,7 +152,6 @@ export function useReports() {
customFrom?: string,
customTo?: string,
pivotCfg?: PivotConfig,
srcId?: number | null,
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
@ -124,21 +161,21 @@ export function useReports() {
switch (tab) {
case "trends": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
const data = await getMonthlyTrends(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break;
}
case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
const data = await getExpensesByCategory(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break;
}
case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
const data = await getCategoryOverTime(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break;
@ -170,8 +207,8 @@ export function useReports() {
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
@ -181,9 +218,18 @@ export function useReports() {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
let newMonth = state.budgetMonth + delta;
let newYear = state.budgetYear;
if (newMonth < 1) {
newMonth = 12;
newYear -= 1;
} else if (newMonth > 12) {
newMonth = 1;
newYear += 1;
}
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.budgetYear, state.budgetMonth]);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
@ -193,9 +239,5 @@ export function useReports() {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []);
const setSourceId = useCallback((id: number | null) => {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig };
}

View file

@ -2,11 +2,6 @@
"app": {
"name": "Simpl'Result"
},
"changelog": {
"title": "Version History",
"description": "View what's new and fixed in each version",
"empty": "No entries available"
},
"nav": {
"dashboard": "Dashboard",
"import": "Import",
@ -22,12 +17,9 @@
"balance": "Balance",
"income": "Income",
"expenses": "Expenses",
"net": "Net",
"noData": "No data available. Start by importing your bank statements.",
"expensesByCategory": "Expenses by Category",
"recentTransactions": "Recent Transactions",
"budgetVsActual": "Budget vs Actual",
"expensesOverTime": "Expenses Over Time",
"period": {
"month": "This month",
"3months": "3 months",
@ -319,18 +311,13 @@
"actual": "Actual",
"difference": "Difference",
"annual": "Annual",
"previousYear": "Prev. Year",
"splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts",
"clickToEdit": "Click to edit",
"applyToMonth": "Apply to month",
"allMonths": "All 12 months",
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers",
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers",
"totalPlanned": "Total Planned",
"totalActual": "Total Actual",
"totalDifference": "Difference",
@ -366,19 +353,12 @@
"showAmounts": "Show amounts",
"hideAmounts": "Hide amounts"
},
"filters": {
"title": "Categories",
"search": "Search...",
"all": "All",
"none": "None"
},
"bva": {
"monthly": "Monthly",
"ytd": "Year-to-Date",
"dollarVar": "$ Var",
"pctVar": "% Var",
"noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
"noData": "No budget or transaction data for this period."
},
"dynamic": "Dynamic Report",
"export": "Export",
@ -435,7 +415,14 @@
"installing": "Installing...",
"error": "Update failed",
"retryButton": "Retry",
"releaseNotes": "What's New"
"releaseNotes": "What's New",
"notes": {
"0.4.0": "### Added\n- Categories: support for 3 levels of hierarchy (e.g., Recurring Expenses → Insurance → Car Insurance)\n- Dynamic Report: new \"Category (Level 3)\" pivot field\n- Budget: intermediate subtotals and 3-level indentation for nested categories\n- Categories: automatic `is_inputable` management when creating/deleting subcategories\n- Categories: depth validation prevents creating a 4th level\n\n### Fixed\n- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly\n- Auto-categorization: pre-compile regex patterns for better batch performance",
"0.3.11": "### Added\n- Dynamic Report: support multiple column dimensions (composite column keys)\n\n### Fixed\n- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters",
"0.3.10": "### Added\n- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)\n- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)\n- \"This year\" period option in reports and dashboard (Jan 1 to today)",
"0.3.9": "### Added\n- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values\n- Delete keywords from the \"All Keywords\" view",
"0.3.8": "### Added\n- Custom date range picker for reports and dashboard\n- Toggle to position subtotals above or below detail rows\n- Display release notes from CHANGELOG in GitHub releases and in-app updater"
}
},
"dataManagement": {
"title": "Data Management",
@ -590,21 +577,18 @@
"features": [
"Balance, income, and expense summary cards",
"Expense breakdown by category (pie chart with SVG patterns)",
"Budget vs Actual table for the current month (variance in $ and %)",
"Stacked bar chart of expenses by category and month",
"Adjustable time period selector (default: year to date)",
"Recent transactions list",
"Adjustable time period selector",
"Context menu (right-click) to hide a category or view its transactions"
],
"steps": [
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
"Review the summary cards for your balance, total income, and total expenses",
"Check the pie chart to see how your spending is distributed across categories",
"Review the Budget vs Actual table to compare your spending against your plan for the current month",
"Analyze the stacked bar chart at the bottom to see expense trends by category over time",
"Right-click a category in a chart to hide it or view its transaction details"
"Right-click a category in the chart to hide it or view its transaction details",
"Scroll down to see your most recent transactions"
],
"tips": [
"The default period is year to date for an annual overview when you open the app",
"The balance is calculated as income minus expenses for the selected period",
"Hidden categories appear as dismissible chips above the chart — click Show All to restore them",
"SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color"

View file

@ -2,11 +2,6 @@
"app": {
"name": "Simpl'Résultat"
},
"changelog": {
"title": "Historique des versions",
"description": "Consultez les nouveautés et corrections de chaque version",
"empty": "Aucune entrée disponible"
},
"nav": {
"dashboard": "Tableau de bord",
"import": "Importer",
@ -22,12 +17,9 @@
"balance": "Solde",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Net",
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
"expensesByCategory": "Dépenses par catégorie",
"recentTransactions": "Transactions récentes",
"budgetVsActual": "Budget vs Réel",
"expensesOverTime": "Dépenses dans le temps",
"period": {
"month": "Ce mois",
"3months": "3 mois",
@ -319,18 +311,13 @@
"actual": "Réel",
"difference": "Écart",
"annual": "Annuel",
"previousYear": "Année préc.",
"splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
"clickToEdit": "Cliquer pour modifier",
"applyToMonth": "Appliquer au mois",
"allMonths": "Les 12 mois",
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts",
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts",
"totalPlanned": "Total prévu",
"totalActual": "Total réel",
"totalDifference": "Écart",
@ -366,19 +353,12 @@
"showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants"
},
"filters": {
"title": "Catégories",
"search": "Rechercher...",
"all": "Toutes",
"none": "Aucune"
},
"bva": {
"monthly": "Mensuel",
"ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"titlePrefix": "Budget vs Réel pour le mois de"
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
},
"dynamic": "Rapport dynamique",
"export": "Exporter",
@ -435,7 +415,14 @@
"installing": "Installation en cours...",
"error": "Erreur lors de la mise à jour",
"retryButton": "Réessayer",
"releaseNotes": "Nouveautés"
"releaseNotes": "Nouveautés",
"notes": {
"0.4.0": "### Ajouté\n- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)\n- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »\n- Budget : sous-totaux intermédiaires et indentation 3 niveaux\n- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories\n- Catégories : validation de profondeur empêche la création d'un 4e niveau\n\n### Corrigé\n- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus\n- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot",
"0.3.11": "### Ajouté\n- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)\n\n### Corrigé\n- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau",
"0.3.10": "### Ajouté\n- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)\n- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)\n- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)",
"0.3.9": "### Ajouté\n- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs\n- Suppression de mots-clés depuis la vue « Tous les mots-clés »",
"0.3.8": "### Ajouté\n- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord\n- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail\n- Affichage des notes de version du CHANGELOG dans les releases GitHub et le système de mise à jour"
}
},
"dataManagement": {
"title": "Gestion des données",
@ -590,21 +577,18 @@
"features": [
"Cartes résumées du solde, des revenus et des dépenses",
"Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)",
"Tableau Budget vs Réel du mois courant (écart en $ et %)",
"Histogramme empilé des dépenses par catégorie et par mois",
"Sélecteur de période ajustable (par défaut : année à ce jour)",
"Liste des transactions récentes",
"Sélecteur de période ajustable",
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
],
"steps": [
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
"Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant",
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps",
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
"Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions",
"Faites défiler vers le bas pour voir vos transactions les plus récentes"
],
"tips": [
"La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture",
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
"Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer",
"Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs"

View file

@ -90,7 +90,7 @@ export default function CategoriesPage() {
onRemove={removeKeyword}
/>
) : (
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}>
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
<CategoryTree
tree={state.tree}

View file

@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
// Version heading: ## [0.6.0] or ## 0.6.0
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
// Section heading: ### Added, ### Corrigé, etc.
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
// List item
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogPage() {
const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Link
to="/settings"
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
</div>
{entries.length === 0 ? (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
) : (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h2 className="text-lg font-semibold">{entry.version}</h2>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h3 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h3>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"\u2022 "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,22 +1,47 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "year": from = new Date(year, 0, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
default: from = new Date(year, month, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export default function DashboardPage() {
const { t, i18n } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
const { t } = useTranslation();
const { state, setPeriod, setCustomDates } = useDashboard();
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -65,8 +90,6 @@ export default function DashboardPage() {
},
];
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
return (
@ -85,7 +108,7 @@ export default function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{cards.map((card) => (
<div
key={card.labelKey}
@ -102,9 +125,7 @@ export default function DashboardPage() {
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
<div className="lg:col-span-1">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
@ -112,38 +133,7 @@ export default function DashboardPage() {
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div>
<div className="lg:col-span-3">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</h2>
<BudgetVsActualTable data={budgetVsActual} />
</div>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
<RecentTransactionsList transactions={recentTransactions} />
</div>
{detailModal && (

View file

@ -1,40 +1,52 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Hash, Table, BarChart3 } from "lucide-react";
import { useReports } from "../hooks/useReports";
import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
import { getAllSources } from "../services/importSourceService";
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthNavigator from "../components/budget/MonthNavigator";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryTable from "../components/reports/CategoryTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
export default function ReportsPage() {
const { t, i18n } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
const [sources, setSources] = useState<ImportSource[]>([]);
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "year": from = new Date(year, 0, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
default: from = new Date(year, month, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
useEffect(() => {
getAllSources().then(setSources);
}, []);
export default function ReportsPage() {
const { t } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true");
const [viewMode, setViewMode] = useState<"chart" | "table">(() =>
(localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart"
);
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
@ -53,52 +65,20 @@ export default function ReportsPage() {
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
const filterCategories = useMemo(() => {
if (state.tab === "byCategory") {
return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color }));
}
if (state.tab === "overTime") {
return state.categoryOverTime.categories.map((name) => ({
name,
color: state.categoryOverTime.colors[name] || "#9ca3af",
}));
}
return [];
}, [state.tab, state.categorySpending, state.categoryOverTime]);
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
{state.tab === "budgetVsActual" ? (
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</h1>
) : (
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
)}
<PageHelp helpKey="reports" />
</div>
{state.tab !== "budgetVsActual" && (
{state.tab === "budgetVsActual" ? (
<MonthNavigator
year={state.budgetYear}
month={state.budgetMonth}
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector
value={state.period}
onChange={setPeriod}
@ -109,7 +89,7 @@ export default function ReportsPage() {
)}
</div>
<div className="flex gap-2 mb-6 flex-wrap items-center">
<div className="flex gap-2 mb-6 flex-wrap">
{TABS.map((tab) => (
<button
key={tab}
@ -123,54 +103,6 @@ export default function ReportsPage() {
{t(`reports.${tab}`)}
</button>
))}
{["trends", "byCategory", "overTime"].includes(state.tab) && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
{([
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
]).map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => {
setViewMode(mode);
localStorage.setItem("reports-view-mode", mode);
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
{viewMode === "chart" && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
<button
onClick={() => {
setShowAmounts((prev) => {
const next = !prev;
localStorage.setItem("reports-show-amounts", String(next));
return next;
});
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showAmounts
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
>
<Hash size={14} />
{showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
</button>
</>
)}
</>
)}
</div>
{state.error && (
@ -179,42 +111,24 @@ export default function ReportsPage() {
</div>
)}
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
{state.tab === "trends" && (
viewMode === "chart" ? (
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
) : (
<MonthlyTrendsTable data={state.monthlyTrends} />
)
)}
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
{state.tab === "byCategory" && (
viewMode === "chart" ? (
<CategoryBarChart
data={state.categorySpending}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "overTime" && (
viewMode === "chart" ? (
<CategoryOverTimeChart
data={state.categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryOverTimeTable data={state.categoryOverTime} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
@ -226,19 +140,6 @@ export default function ReportsPage() {
onConfigChange={setPivotConfig}
/>
)}
</div>
{showFilterPanel && (
<ReportFilterPanel
categories={filterCategories}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
sources={sources}
selectedSourceId={state.sourceId}
onSourceChange={setSourceId}
/>
)}
</div>
{detailModal && (
<TransactionDetailModal

View file

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Info,
@ -11,7 +11,6 @@ import {
ShieldCheck,
BookOpen,
ChevronRight,
FileText,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { useUpdater } from "../hooks/useUpdater";
@ -22,44 +21,15 @@ import DataManagementCard from "../components/settings/DataManagementCard";
import LogViewerCard from "../components/settings/LogViewerCard";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater();
const [version, setVersion] = useState("");
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const fetchReleaseNotes = useCallback(
(targetVersion: string) => {
const file =
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => {
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
"m",
);
const match = text.match(re);
setReleaseNotes(
match ? match[1].trim() : null,
);
})
.catch(() => setReleaseNotes(null));
},
[i18n.language],
);
useEffect(() => {
getVersion().then(setVersion);
}, []);
useEffect(() => {
if (state.status === "available" && state.version) {
fetchReleaseNotes(state.version);
}
}, [state.status, state.version, fetchReleaseNotes]);
const progressPercent =
state.contentLength && state.contentLength > 0
? Math.round((state.progress / state.contentLength) * 100)
@ -108,27 +78,6 @@ export default function SettingsPage() {
</div>
</Link>
{/* Changelog card */}
<Link
to="/changelog"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FileText size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("changelog.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* Update card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
@ -178,7 +127,9 @@ export default function SettingsPage() {
{t("settings.updates.available", { version: state.version })}
</p>
{(() => {
const notes = releaseNotes || state.body;
const notesKey = `settings.updates.notes.${state.version}`;
const i18nNotes = t(notesKey, { defaultValue: "" });
const notes = i18nNotes || state.body;
if (!notes) return null;
return (
<div className="space-y-2">

View file

@ -178,16 +178,6 @@ export async function deleteTemplate(templateId: number): Promise<void> {
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
}
// --- Actuals helpers ---
export async function getActualTotalsForYear(
year: number
): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo);
}
// --- Budget vs Actual ---
async function getActualsByCategoryRange(
@ -241,6 +231,7 @@ export async function getBudgetVsActualData(
}
// Index categories
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, Category[]>();
for (const cat of allCategories) {
if (cat.parent_id) {
@ -253,7 +244,7 @@ export async function getBudgetVsActualData(
const signFor = (type: string) => (type === "expense" ? -1 : 1);
// Compute leaf row values
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
const sign = signFor(cat.type);
const monthMap = entryMap.get(cat.id);
const rawMonthBudget = monthMap?.get(month) ?? 0;
@ -290,7 +281,7 @@ export async function getBudgetVsActualData(
};
}
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
const row: BudgetVsActualRow = {
category_id: cat.id,
category_name: cat.name,
@ -332,41 +323,35 @@ export async function getBudgetVsActualData(
);
}
// Build rows for a sub-group (recursive, supports arbitrary depth)
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
const subChildren = childrenByParent.get(cat.id) || [];
const hasSubChildren = subChildren.some(
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
);
if (!hasSubChildren && cat.is_inputable) {
const leaf = buildLeaf(cat, groupParentId, depth);
// Build rows for a level-2 parent (intermediate parent with grandchildren)
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at level 2
const leaf = buildLeaf(cat, grandparentId, 2);
return isRowAllZero(leaf) ? [] : [leaf];
}
if (!hasSubChildren) return [];
if (grandchildren.length === 0) return [];
const childRows: BudgetVsActualRow[] = [];
const gcRows: BudgetVsActualRow[] = [];
if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id, depth + 1);
const direct = buildLeaf(cat, cat.id, 2);
direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) childRows.push(direct);
if (!isRowAllZero(direct)) gcRows.push(direct);
}
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
for (const child of sortedSubChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, depth + 1);
childRows.push(...subRows);
} else if (child.is_inputable) {
const leaf = buildLeaf(child, cat.id, depth + 1);
if (!isRowAllZero(leaf)) childRows.push(leaf);
for (const gc of grandchildren) {
const leaf = buildLeaf(gc, cat.id, 2);
if (!isRowAllZero(leaf)) gcRows.push(leaf);
}
}
if (childRows.length === 0) return [];
if (gcRows.length === 0) return [];
const leafRows = childRows.filter((r) => !r.is_parent);
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
return [subtotal, ...childRows];
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1;
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return [subtotal, ...gcRows];
}
const rows: BudgetVsActualRow[] = [];
@ -374,15 +359,15 @@ export async function getBudgetVsActualData(
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];
const hasChildren = children.some(
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
);
const inputableChildren = children.filter((c) => c.is_inputable);
// Also check for non-inputable intermediate parents that have their own children
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
if (!hasChildren && cat.is_inputable) {
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf at level 0
const leaf = buildLeaf(cat, null, 0);
if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (hasChildren) {
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetVsActualRow[] = [];
// Direct transactions on the parent itself
@ -392,19 +377,25 @@ export async function getBudgetVsActualData(
if (!isRowAllZero(direct)) allChildRows.push(direct);
}
// Process children in alphabetical order
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
for (const child of sortedChildren) {
// Level-2 leaves (direct children that are inputable and have no children)
for (const child of inputableChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, 1);
allChildRows.push(...subRows);
} else if (child.is_inputable) {
if (grandchildren.length === 0) {
const leaf = buildLeaf(child, cat.id, 1);
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
} else {
// This child has its own children — it's an intermediate parent at level 1
const subRows = buildLevel2Group(child, cat.id);
allChildRows.push(...subRows);
}
}
// Non-inputable intermediate parents at level 1
for (const ip of intermediateParents) {
const subRows = buildLevel2Group(ip, cat.id);
allChildRows.push(...subRows);
}
if (allChildRows.length === 0) continue;
// Collect only leaf rows for parent subtotal (avoid double-counting)
@ -412,19 +403,51 @@ export async function getBudgetVsActualData(
const parent = buildSubtotal(cat, leafRows, null, 0);
rows.push(parent);
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows);
}
}
// Sort by type only, preserving tree order within groups (already built correctly)
const rowOrder = new Map<BudgetVsActualRow, number>();
rows.forEach((r, i) => rowOrder.set(r, i));
// Sort by type, then within same type keep parent+children groups together
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
return rowOrder.get(a)! - rowOrder.get(b)!;
// Find the top-level group id
function getGroupId(r: BudgetVsActualRow): number {
if (r.depth === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
// Walk up to find the root
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
const groupA = getGroupId(a);
const groupB = getGroupId(b);
if (groupA !== groupB) {
const catA = catById.get(groupA);
const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999;
const orderB = catB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Within same group: sort by depth, then parent before children
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return rows;

View file

@ -53,8 +53,7 @@ export async function getDashboardSummary(
export async function getExpensesByCategory(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
dateTo?: string
): Promise<CategoryBreakdownItem[]> {
const db = await getDb();
@ -72,11 +71,6 @@ export async function getExpensesByCategory(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;

View file

@ -12,8 +12,7 @@ import type {
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
dateTo?: string
): Promise<MonthlyTrendItem[]> {
const db = await getDb();
@ -31,11 +30,6 @@ export async function getMonthlyTrends(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
@ -56,8 +50,7 @@ export async function getMonthlyTrends(
export async function getCategoryOverTime(
dateFrom?: string,
dateTo?: string,
topN: number = 50,
sourceId?: number,
topN: number = 8
): Promise<CategoryOverTimeData> {
const db = await getDb();
@ -75,11 +68,6 @@ export async function getCategoryOverTime(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;

View file

@ -139,10 +139,9 @@ export interface BudgetYearRow {
category_type: "expense" | "income" | "transfer";
parent_id: number | null;
is_parent: boolean;
depth?: number;
depth?: 0 | 1 | 2;
months: number[]; // index 0-11 = Jan-Dec planned amounts
annual: number; // computed sum
previousYearTotal: number; // actual (transactions) total from the previous year
}
export interface ImportConfigTemplate {
@ -332,7 +331,7 @@ export interface BudgetVsActualRow {
category_type: "expense" | "income" | "transfer";
parent_id: number | null;
is_parent: boolean;
depth?: number;
depth?: 0 | 1 | 2;
monthActual: number;
monthBudget: number;
monthVariation: number;

View file

@ -1,123 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { computeDateRange, buildMonthOptions } from "./dateRange";
describe("computeDateRange", () => {
beforeEach(() => {
// Fix "now" to 2025-07-15 for deterministic tests
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
});
afterEach(() => {
vi.useRealTimers();
});
it('returns empty object for "all" period', () => {
expect(computeDateRange("all")).toEqual({});
});
it('returns custom range for "custom" period', () => {
expect(computeDateRange("custom", "2025-01-01", "2025-06-30")).toEqual({
dateFrom: "2025-01-01",
dateTo: "2025-06-30",
});
});
it('falls back to default when "custom" has missing dates', () => {
const result = computeDateRange("custom");
// Should fall through to default (same as "month")
expect(result.dateFrom).toBe("2025-07-01");
expect(result.dateTo).toBe("2025-07-15");
});
it('computes "month" period (first of current month to today)', () => {
const result = computeDateRange("month");
expect(result).toEqual({ dateFrom: "2025-07-01", dateTo: "2025-07-15" });
});
it('computes "3months" period (3 months back)', () => {
const result = computeDateRange("3months");
expect(result).toEqual({ dateFrom: "2025-05-01", dateTo: "2025-07-15" });
});
it('computes "6months" period (6 months back)', () => {
const result = computeDateRange("6months");
expect(result).toEqual({ dateFrom: "2025-02-01", dateTo: "2025-07-15" });
});
it('computes "year" period (Jan 1st of current year)', () => {
const result = computeDateRange("year");
expect(result).toEqual({ dateFrom: "2025-01-01", dateTo: "2025-07-15" });
});
it('computes "12months" period (12 months back)', () => {
const result = computeDateRange("12months");
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-07-15" });
});
it("handles January rollover for 3months period", () => {
vi.setSystemTime(new Date(2025, 1, 10)); // Feb 10, 2025
const result = computeDateRange("3months");
expect(result).toEqual({ dateFrom: "2024-12-01", dateTo: "2025-02-10" });
});
it("handles January rollover for 6months period", () => {
vi.setSystemTime(new Date(2025, 0, 20)); // Jan 20, 2025
const result = computeDateRange("6months");
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-01-20" });
});
it("handles January rollover for 12months period", () => {
vi.setSystemTime(new Date(2025, 0, 5)); // Jan 5, 2025
const result = computeDateRange("12months");
expect(result).toEqual({ dateFrom: "2024-02-01", dateTo: "2025-01-05" });
});
});
describe("buildMonthOptions", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
});
afterEach(() => {
vi.useRealTimers();
});
it("returns 24 month options", () => {
const options = buildMonthOptions("fr");
expect(options).toHaveLength(24);
});
it("starts with the current month", () => {
const options = buildMonthOptions("en");
expect(options[0].key).toBe("2025-7");
expect(options[0].value).toBe("2025-7");
});
it("ends 23 months ago", () => {
const options = buildMonthOptions("en");
expect(options[23].key).toBe("2023-8");
expect(options[23].value).toBe("2023-8");
});
it("handles January rollover correctly", () => {
vi.setSystemTime(new Date(2025, 0, 15)); // Jan 15, 2025
const options = buildMonthOptions("en");
expect(options[0].key).toBe("2025-1");
expect(options[1].key).toBe("2024-12");
expect(options[12].key).toBe("2024-1");
});
it("capitalizes the first letter of labels", () => {
const options = buildMonthOptions("fr");
for (const opt of options) {
expect(opt.label[0]).toBe(opt.label[0].toUpperCase());
}
});
it("labels contain year information", () => {
const options = buildMonthOptions("en");
expect(options[0].label).toContain("2025");
});
});

View file

@ -1,66 +0,0 @@
import type { DashboardPeriod } from "../shared/types";
/**
* Compute a date range (dateFrom / dateTo) based on the selected period.
* Shared between useDashboard, useReports, DashboardPage and ReportsPage.
*/
export function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
/**
* Build an array of month options for the budget month dropdown.
* Returns the last 24 months with localized labels.
*/
export function buildMonthOptions(language: string): Array<{ key: string; value: string; label: string }> {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
return Array.from({ length: 24 }, (_, i) => {
const d = new Date(currentYear, currentMonth - i, 1);
const y = d.getFullYear();
const m = d.getMonth() + 1;
const label = new Intl.DateTimeFormat(language, { month: "long", year: "numeric" }).format(d);
return { key: `${y}-${m}`, value: `${y}-${m}`, label: label.charAt(0).toUpperCase() + label.slice(1) };
});
}

View file

@ -1,38 +0,0 @@
/**
* Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled.
*/
export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows;
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
const result: T[] = [];
let currentParent: T | null = null;
let currentChildren: T[] = [];
for (const row of groupRows) {
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
// Flush previous group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
currentChildren = [];
}
currentParent = row;
} else if (currentParent) {
currentChildren.push(row);
} else {
result.push(row);
}
}
// Flush last group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
}
return result;
}
return reorderGroup(rows, 0);
}

View file

@ -1,31 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { copyFileSync } from "fs";
import { resolve } from "path";
// Sync root CHANGELOG files to public/ so the app always shows the latest version history
function syncChangelogs() {
const root = import.meta.dirname;
const files = ["CHANGELOG.md", "CHANGELOG.fr.md"];
for (const file of files) {
try {
copyFileSync(resolve(root, file), resolve(root, "public", file));
} catch {
// Ignore if source file doesn't exist
}
}
}
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => {
// Sync changelogs before starting dev server or building
syncChangelogs();
return {
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
@ -49,5 +30,4 @@ export default defineConfig(async () => {
ignored: ["**/src-tauri/**"],
},
},
};
});
}));