Compare commits

..

No commits in common. "main" and "issue-96-compare-refactor" have entirely different histories.

36 changed files with 31 additions and 2378 deletions

View file

@ -2,12 +2,6 @@
## [Non publié]
## [0.8.2] - 2026-04-17
### Ajouté
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)

View file

@ -2,12 +2,6 @@
## [Unreleased]
## [0.8.2] - 2026-04-17
### Added
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)

View file

@ -125,7 +125,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle) |
| `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
@ -151,7 +151,6 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
@ -290,7 +289,6 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |

View file

@ -309,7 +309,6 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
@ -319,10 +318,9 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
5. Utilisez la section Gestion des données pour exporter ou importer vos données
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
4. Utilisez la section Gestion des données pour exporter ou importer vos données
5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
### Astuces
@ -331,5 +329,4 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
- En cas de problème, copiez les journaux et joignez-les à votre signalement

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.8.2",
"version": "0.8.0",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View file

@ -4423,7 +4423,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simpl-result"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"aes-gcm",
"argon2",

View file

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

View file

@ -1,159 +0,0 @@
// Feedback Hub client — forwards user-submitted feedback to the central
// feedback-api service. Routed through Rust (not direct fetch) so that:
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
// - The exact payload leaving the machine is auditable in a single place
// - The pattern matches the other outbound calls (OAuth, license, updater)
//
// The feedback-api contract is documented in
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
// any context key outside its whitelist, so this module only sends the
// fields declared in `Context` below.
use serde::{Deserialize, Serialize};
use std::time::Duration;
fn feedback_endpoint() -> String {
std::env::var("FEEDBACK_HUB_URL")
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
}
/// Context payload sent with a feedback submission. Keys MUST match the
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
/// silently. Each field is capped at 500 chars server-side.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeedbackContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct FeedbackPayload<'a> {
app_id: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<FeedbackContext>,
}
#[derive(Debug, Serialize)]
pub struct FeedbackSuccess {
pub id: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct FeedbackResponse {
id: String,
created_at: String,
}
/// Return a composed User-Agent string for the context payload, e.g.
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
/// pull in an extra Tauri plugin just for this.
#[tauri::command]
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
let version = app.package_info().version.to_string();
let os = std::env::consts::OS;
format!("Simpl'Résultat/{} ({})", version, os)
}
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
/// ("invalid", "rate_limit", "server_error", "network_error") that the
/// frontend maps to i18n messages.
#[tauri::command]
pub async fn send_feedback(
content: String,
user_id: Option<String>,
context: Option<FeedbackContext>,
) -> Result<FeedbackSuccess, String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("invalid".to_string());
}
let payload = FeedbackPayload {
app_id: "simpl-resultat",
content: trimmed,
user_id,
context,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| "network_error".to_string())?;
let url = format!("{}/api/feedback", feedback_endpoint());
let res = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|_| "network_error".to_string())?;
match res.status().as_u16() {
201 => {
let body: FeedbackResponse = res
.json()
.await
.map_err(|_| "server_error".to_string())?;
Ok(FeedbackSuccess {
id: body.id,
created_at: body.created_at,
})
}
400 => Err("invalid".to_string()),
429 => Err("rate_limit".to_string()),
_ => Err("server_error".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_skips_none_fields() {
let ctx = FeedbackContext {
page: Some("/settings".to_string()),
locale: Some("fr".to_string()),
theme: None,
viewport: None,
user_agent: None,
timestamp: None,
};
let json = serde_json::to_value(&ctx).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("page"));
assert!(obj.contains_key("locale"));
}
#[test]
fn context_serializes_user_agent_camelcase() {
let ctx = FeedbackContext {
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
..Default::default()
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("\"userAgent\""));
assert!(!json.contains("\"user_agent\""));
}
#[tokio::test]
async fn empty_content_is_rejected_locally() {
let res = send_feedback(" \n\t".to_string(), None, None).await;
assert_eq!(res.unwrap_err(), "invalid");
}
}

View file

@ -2,7 +2,6 @@ pub mod account_cache;
pub mod auth_commands;
pub mod entitlements;
pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands;
@ -11,7 +10,6 @@ pub mod token_store;
pub use auth_commands::*;
pub use entitlements::*;
pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*;
pub use license_commands::*;
pub use profile_commands::*;

View file

@ -185,8 +185,6 @@ pub fn run() {
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.8.2",
"version": "0.8.0",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",
@ -18,7 +18,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
}
},
"bundle": {

View file

@ -14,7 +14,6 @@ import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
@ -110,7 +109,6 @@ export default function App() {
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import { Target } from "lucide-react";
import type { CartesBudgetAdherence } from "../../../shared/types";
export interface BudgetAdherenceCardProps {
adherence: CartesBudgetAdherence;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
const { t, i18n } = useTranslation();
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Target size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.budgetAdherenceTitle")}
</h3>
</div>
{categoriesTotal === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.budgetAdherenceEmpty")}
</div>
) : (
<>
<div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{categoriesInTarget}
<span className="text-sm text-[var(--muted-foreground)] font-normal">
{" / "}
{categoriesTotal}
</span>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceSubtitle", {
score:
score !== null
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
}).format(score / 100)
: "—",
})}
</div>
</div>
{worstOverruns.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceWorst")}
</div>
{worstOverruns.map((r) => {
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
return (
<div key={r.categoryId} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: r.categoryColor }}
/>
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
</span>
<span className="tabular-nums text-[var(--muted-foreground)]">
{formatCurrency(r.actual, i18n.language)}
{" / "}
{formatCurrency(r.budget, i18n.language)}
<span className="text-[var(--negative)] ml-1 font-medium">
{formatPct(r.overrunPct, i18n.language)}
</span>
</span>
</div>
<div
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
aria-hidden
>
<div
className="h-full bg-[var(--negative)]"
style={{ width: `${Math.min(progressPct, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}

View file

@ -1,97 +0,0 @@
import { useTranslation } from "react-i18next";
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import type { CartesMonthFlow } from "../../../shared/types";
export interface IncomeExpenseOverlayChartProps {
flow: CartesMonthFlow[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatMonthShort(month: string, language: string): string {
const [y, m] = month.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "short",
year: "2-digit",
}).format(new Date(y, m - 1, 1));
}
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
const { t, i18n } = useTranslation();
if (flow.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const data = flow.map((p) => ({
...p,
label: formatMonthShort(p.month, i18n.language),
}));
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
{t("reports.cartes.flowChartTitle")}
</div>
<ResponsiveContainer width="100%" height={260}>
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v) => formatCurrency(v, i18n.language)}
width={80}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<ReferenceLine y={0} stroke="var(--border)" />
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
<Line
type="monotone"
dataKey="net"
name={t("reports.cartes.net")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 2 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,139 +0,0 @@
import { useTranslation } from "react-i18next";
import KpiSparkline from "./KpiSparkline";
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
export interface KpiCardProps {
id: CartesKpiId;
title: string;
kpi: CartesKpi;
format: "currency" | "percent";
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
deltaIsBadWhenUp?: boolean;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPercent(value: number, language: string, signed = false): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(value / 100);
}
function formatValue(value: number, format: "currency" | "percent", language: string): string {
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
}
function formatDeltaAbs(
value: number,
format: "currency" | "percent",
language: string,
): string {
if (format === "currency") {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(value);
}
// Savings rate delta in percentage points — not a % of %
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
maximumFractionDigits: 1,
signDisplay: "always",
}).format(value);
return `${formatted} pt`;
}
interface DeltaBadgeProps {
abs: number | null;
pct: number | null;
label: string;
format: "currency" | "percent";
language: string;
deltaIsBadWhenUp: boolean;
}
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
if (abs === null) {
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className="text-xs text-[var(--muted-foreground)] italic"></span>
</div>
);
}
const isUp = abs >= 0;
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
// Treat near-zero as neutral
const isNeutral = abs === 0;
const colorClass = isNeutral
? "text-[var(--muted-foreground)]"
: isBad
? "text-[var(--negative)]"
: "text-[var(--positive)]";
const absText = formatDeltaAbs(abs, format, language);
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
{absText}
{pctText}
</span>
</div>
);
}
export default function KpiCard({
id,
title,
kpi,
format,
deltaIsBadWhenUp = false,
}: KpiCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
return (
<div
data-kpi={id}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
>
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatValue(kpi.current, format, language)}
</div>
<KpiSparkline data={kpi.sparkline} />
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
<DeltaBadge
abs={kpi.deltaMoMAbs}
pct={kpi.deltaMoMPct}
label={t("reports.cartes.deltaMoMLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
<DeltaBadge
abs={kpi.deltaYoYAbs}
pct={kpi.deltaYoYPct}
label={t("reports.cartes.deltaYoYLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
</div>
</div>
);
}

View file

@ -1,50 +0,0 @@
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
import type { CartesSparklinePoint } from "../../../shared/types";
export interface KpiSparklineProps {
data: CartesSparklinePoint[];
color?: string;
height?: number;
}
/**
* Compact line chart with the reference month (the last point) highlighted
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
*/
export default function KpiSparkline({
data,
color = "var(--primary)",
height = 40,
}: KpiSparklineProps) {
if (data.length === 0) {
return <div style={{ width: "100%", height }} />;
}
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
const lastIndex = chartData.length - 1;
const lastValue = chartData[lastIndex]?.value ?? 0;
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.75}
dot={false}
isAnimationActive={false}
/>
<ReferenceDot
x={lastIndex}
y={lastValue}
r={3.5}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -1,106 +0,0 @@
import { useTranslation } from "react-i18next";
import { CalendarClock } from "lucide-react";
import type { CartesSeasonality } from "../../../shared/types";
export interface SeasonalityCardProps {
seasonality: CartesSeasonality;
referenceYear: number;
referenceMonth: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number, language: string, signed = true): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(pct / 100);
}
function formatMonthYear(year: number, month: number, language: string): string {
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(new Date(year, month - 1, 1));
}
export default function SeasonalityCard({
seasonality,
referenceYear,
referenceMonth,
}: SeasonalityCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CalendarClock size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.seasonalityTitle")}
</h3>
</div>
{historicalYears.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.seasonalityEmpty")}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(referenceAmount, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{historicalYears.map((y) => (
<div
key={y.year}
className="flex items-center justify-between text-[var(--muted-foreground)]"
>
<span>{y.year}</span>
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
</div>
))}
{historicalAverage !== null && (
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
<span>{t("reports.cartes.seasonalityAverage")}</span>
<span className="tabular-nums font-medium">
{formatCurrency(historicalAverage, language)}
</span>
</div>
)}
</div>
{deviationPct !== null && (
<div
className={`text-xs font-medium ${
deviationPct > 5
? "text-[var(--negative)]"
: deviationPct < -5
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
>
{t("reports.cartes.seasonalityDeviation", {
pct: formatPct(deviationPct, language),
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,86 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TrendingUp, TrendingDown } from "lucide-react";
import type { CartesTopMover } from "../../../shared/types";
export interface TopMoversListProps {
movers: CartesTopMover[];
direction: "up" | "down";
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function categoryHref(categoryId: number | null): string {
if (categoryId === null) return "/transactions";
const params = new URLSearchParams(window.location.search);
params.set("cat", String(categoryId));
return `/reports/category?${params.toString()}`;
}
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
const { t, i18n } = useTranslation();
const title =
direction === "up"
? t("reports.cartes.topMoversUp")
: t("reports.cartes.topMoversDown");
const Icon = direction === "up" ? TrendingUp : TrendingDown;
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon size={16} className={accentClass} />
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
</div>
{movers.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.empty.noData")}
</div>
) : (
<ul className="flex flex-col gap-1">
{movers.map((m) => (
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
<Link
to={categoryHref(m.categoryId)}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: m.categoryColor }}
/>
<span className="truncate text-sm text-[var(--foreground)]">
{m.categoryName}
</span>
</span>
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
{formatSignedCurrency(m.deltaAbs, i18n.language)}
<span className="text-[var(--muted-foreground)] ml-1">
{formatPct(m.deltaPct, i18n.language)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,67 +0,0 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Globe } from "lucide-react";
interface FeedbackConsentDialogProps {
onAccept: () => void;
onCancel: () => void;
}
export default function FeedbackConsentDialog({
onAccept,
onCancel,
}: FeedbackConsentDialogProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onCancel]);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Globe size={18} />
{t("feedback.consent.title")}
</h2>
<button
onClick={onCancel}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3 text-sm text-[var(--muted-foreground)]">
<p>{t("feedback.consent.body")}</p>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={onAccept}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{t("feedback.consent.accept")}
</button>
</div>
</div>
</div>,
document.body,
);
}

View file

@ -1,239 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
import { useFeedback } from "../../hooks/useFeedback";
import { useAuth } from "../../hooks/useAuth";
import { getRecentErrorLogs } from "../../services/logService";
import {
getFeedbackUserAgent,
type FeedbackContext,
} from "../../services/feedbackService";
const MAX_CONTENT_LENGTH = 2000;
const LOGS_SUFFIX_MAX = 800;
const RECENT_ERROR_LOGS_N = 20;
const AUTO_CLOSE_DELAY_MS = 2000;
interface FeedbackDialogProps {
onClose: () => void;
}
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
const { t, i18n } = useTranslation();
const location = useLocation();
const { state: authState } = useAuth();
const { state: feedbackState, submit, reset } = useFeedback();
const [content, setContent] = useState("");
const [includeContext, setIncludeContext] = useState(false);
const [includeLogs, setIncludeLogs] = useState(false);
const [identify, setIdentify] = useState(false);
const isAuthenticated = authState.status === "authenticated";
const userEmail = authState.account?.email ?? null;
const trimmed = content.trim();
const isSending = feedbackState.status === "sending";
const isSuccess = feedbackState.status === "success";
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && !isSending) onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose, isSending]);
// Auto-close after success
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(() => {
onClose();
reset();
}, AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}, [isSuccess, onClose, reset]);
const errorMessage = useMemo(() => {
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
switch (feedbackState.errorCode) {
case "rate_limit":
return t("feedback.toast.error.429");
case "invalid":
return t("feedback.toast.error.400");
case "network_error":
case "server_error":
default:
return t("feedback.toast.error.generic");
}
}, [feedbackState, t]);
async function handleSubmit() {
if (!canSubmit) return;
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
let body = trimmed;
if (includeLogs) {
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
if (rawLogs.length > 0) {
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
const available = MAX_CONTENT_LENGTH - body.length;
if (available > suffix.length) {
body = body + suffix;
} else if (available > 50) {
body = body + suffix.slice(0, available);
}
}
}
// Compose context if opted in
let context: FeedbackContext | undefined;
if (includeContext) {
let userAgent = "Simpl'Résultat";
try {
userAgent = await getFeedbackUserAgent();
} catch {
// fall back to a basic string if the Rust helper fails
}
context = {
page: location.pathname,
locale: i18n.language,
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
viewport: `${window.innerWidth}x${window.innerHeight}`,
userAgent,
timestamp: new Date().toISOString(),
};
}
const userId = identify && isAuthenticated ? userEmail : null;
await submit({ content: body, userId, context });
}
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget && !isSending) onClose();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<MessageSquarePlus size={18} />
{t("feedback.dialog.title")}
</h2>
<button
onClick={onClose}
disabled={isSending}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3">
{isSuccess ? (
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
<CheckCircle size={20} />
<span>{t("feedback.toast.success")}</span>
</div>
) : (
<>
<textarea
value={content}
onChange={(e) =>
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
}
placeholder={t("feedback.dialog.placeholder")}
rows={6}
disabled={isSending}
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
/>
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
{content.length}/{MAX_CONTENT_LENGTH}
</div>
<div className="space-y-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeContext}
onChange={(e) => setIncludeContext(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.context")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeLogs}
onChange={(e) => setIncludeLogs(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.logs")}</span>
</label>
{isAuthenticated && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={identify}
onChange={(e) => setIdentify(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>
{t("feedback.checkbox.identify")}
{userEmail && (
<span className="text-[var(--muted-foreground)]">
{" "}
({userEmail})
</span>
)}
</span>
</label>
)}
</div>
{errorMessage && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
</>
)}
</div>
{!isSuccess && (
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onClose}
disabled={isSending}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isSending
? t("feedback.dialog.sending")
: t("feedback.dialog.submit")}
</button>
</div>
)}
</div>
</div>,
document.body,
);
}

View file

@ -1,37 +1,16 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react";
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() {
const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const openFeedback = () => {
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
if (accepted) {
setFeedbackOpen(true);
} else {
setConsentOpen(true);
}
};
const acceptConsent = () => {
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
setConsentOpen(false);
setFeedbackOpen(true);
};
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
@ -75,13 +54,6 @@ export default function LogViewerCard() {
{t("settings.logs.title")}
</h2>
<div className="flex items-center gap-2">
<button
onClick={openFeedback}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<MessageSquarePlus size={14} />
{t("feedback.button")}
</button>
<button
onClick={handleCopy}
disabled={filtered.length === 0}
@ -139,14 +111,6 @@ export default function LogViewerCard() {
))
)}
</div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div>
);
}

View file

@ -1,25 +0,0 @@
import { describe, it, expect } from "vitest";
import { defaultCartesReferencePeriod } from "./useCartes";
describe("defaultCartesReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 3, 15))).toEqual({
year: 2026,
month: 3,
});
});
it("wraps around January to December of the previous year", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 0, 10))).toEqual({
year: 2025,
month: 12,
});
});
it("handles the last day of a month", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 5, 30))).toEqual({
year: 2026,
month: 5,
});
});
});

View file

@ -1,103 +0,0 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CartesSnapshot } from "../shared/types";
import { getCartesSnapshot } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
year: number;
month: number;
snapshot: CartesSnapshot | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
| { type: "SET_ERROR"; payload: string };
/**
* Default reference period for the Cartes report: the month preceding `today`.
* January wraps around to December of the previous year. Exported for tests.
*/
export function defaultCartesReferencePeriod(
today: Date = new Date(),
): { year: number; month: number } {
const y = today.getFullYear();
const m = today.getMonth() + 1;
if (m === 1) return { year: y - 1, month: 12 };
return { year: y, month: m - 1 };
}
const defaultRef = defaultCartesReferencePeriod();
const initialState: State = {
year: defaultRef.year,
month: defaultRef.month,
snapshot: null,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SNAPSHOT":
return { ...state, snapshot: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCartes() {
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (year: number, month: number) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const snapshot = await getCartesSnapshot(year, month);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.year, state.month);
}, [fetch, state.year, state.month]);
// Keep the reference month in sync with the URL `to` date, so navigating
// via PeriodSelector works as expected.
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return {
...state,
setReferencePeriod,
from,
to,
period,
setPeriod,
setCustomDates,
};
}

View file

@ -1,44 +0,0 @@
import { describe, it, expect } from "vitest";
import {
feedbackReducer,
initialFeedbackState,
type FeedbackState,
} from "./useFeedback";
describe("feedbackReducer", () => {
it("starts in idle with no error", () => {
expect(initialFeedbackState).toEqual({ status: "idle", errorCode: null });
});
it("transitions idle → sending on SEND_START", () => {
const next = feedbackReducer(initialFeedbackState, { type: "SEND_START" });
expect(next).toEqual({ status: "sending", errorCode: null });
});
it("clears a previous error code when re-sending", () => {
const prev: FeedbackState = { status: "error", errorCode: "rate_limit" };
const next = feedbackReducer(prev, { type: "SEND_START" });
expect(next.errorCode).toBeNull();
});
it("transitions sending → success", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, { type: "SEND_SUCCESS" });
expect(next).toEqual({ status: "success", errorCode: null });
});
it("transitions sending → error and records the code", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, {
type: "SEND_ERROR",
code: "network_error",
});
expect(next).toEqual({ status: "error", errorCode: "network_error" });
});
it("RESET returns to the initial state regardless of prior state", () => {
const prev: FeedbackState = { status: "error", errorCode: "invalid" };
const next = feedbackReducer(prev, { type: "RESET" });
expect(next).toEqual(initialFeedbackState);
});
});

View file

@ -1,68 +0,0 @@
import { useCallback, useReducer } from "react";
import {
sendFeedback,
type FeedbackContext,
type FeedbackErrorCode,
isFeedbackErrorCode,
} from "../services/feedbackService";
export type FeedbackStatus = "idle" | "sending" | "success" | "error";
export interface FeedbackState {
status: FeedbackStatus;
errorCode: FeedbackErrorCode | null;
}
export type FeedbackAction =
| { type: "SEND_START" }
| { type: "SEND_SUCCESS" }
| { type: "SEND_ERROR"; code: FeedbackErrorCode }
| { type: "RESET" };
export const initialFeedbackState: FeedbackState = {
status: "idle",
errorCode: null,
};
export function feedbackReducer(
_state: FeedbackState,
action: FeedbackAction,
): FeedbackState {
switch (action.type) {
case "SEND_START":
return { status: "sending", errorCode: null };
case "SEND_SUCCESS":
return { status: "success", errorCode: null };
case "SEND_ERROR":
return { status: "error", errorCode: action.code };
case "RESET":
return initialFeedbackState;
}
}
export interface SubmitArgs {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export function useFeedback() {
const [state, dispatch] = useReducer(feedbackReducer, initialFeedbackState);
const submit = useCallback(async (args: SubmitArgs) => {
dispatch({ type: "SEND_START" });
try {
await sendFeedback(args);
dispatch({ type: "SEND_SUCCESS" });
} catch (e) {
const code: FeedbackErrorCode = isFeedbackErrorCode(e)
? e
: "network_error";
dispatch({ type: "SEND_ERROR", code });
}
}, []);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return { state, submit, reset };
}

View file

@ -358,10 +358,7 @@
"period": "Period",
"byCategory": "Expenses by Category",
"overTime": "Category Over Time",
"trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"trends": "Monthly Trends",
"budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom",
@ -400,9 +397,11 @@
"compare": "Compare",
"compareDescription": "Compare a reference month against previous month, previous year, or budget",
"categoryZoom": "Category Analysis",
"categoryZoomDescription": "Zoom in on a single category",
"cartes": "Cards",
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
"categoryZoomDescription": "Zoom in on a single category"
},
"trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"compare": {
"modeActual": "Actual vs actual",
@ -412,26 +411,6 @@
"subModeAria": "Comparison period",
"referenceMonth": "Reference month"
},
"cartes": {
"kpiSectionAria": "Key indicators for the reference month",
"income": "Income",
"expenses": "Expenses",
"net": "Net balance",
"savingsRate": "Savings rate",
"deltaMoMLabel": "vs last month",
"deltaYoYLabel": "vs last year",
"flowChartTitle": "Income vs expenses — last 12 months",
"topMoversUp": "Biggest increases",
"topMoversDown": "Biggest decreases",
"budgetAdherenceTitle": "Budget adherence",
"budgetAdherenceSubtitle": "{{score}} of budgeted categories on target",
"budgetAdherenceEmpty": "No budgeted categories this month",
"budgetAdherenceWorst": "Worst overruns",
"seasonalityTitle": "Seasonality",
"seasonalityEmpty": "Not enough history for this month",
"seasonalityAverage": "Average",
"seasonalityDeviation": "{{pct}} vs average"
},
"category": {
"selectCategory": "Select a category",
"includeSubcategories": "Include subcategories",
@ -836,8 +815,7 @@
"Application logs viewable with level filters, copy, and clear",
"Data export (transactions, categories, or both) in JSON or CSV format",
"Data import from a previously exported file",
"Optional AES-256-GCM encryption for exported files",
"Optional feedback submission to feedback.lacompagniemaximus.com (explicit exception to the 100% local operation — prompts for consent)"
"Optional AES-256-GCM encryption for exported files"
],
"steps": [
"Click User Guide to access the full documentation",
@ -845,8 +823,7 @@
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
"Use the Data Management section to export or import your data",
"When exporting, choose what to include and optionally set a password for encryption",
"When importing, select a previously exported file — encrypted files will prompt for the password",
"Click Send feedback in the Logs section to share a suggestion, comment, or issue — the identify and context/logs checkboxes are unchecked by default"
"When importing, select a previously exported file — encrypted files will prompt for the password"
],
"tips": [
"Updates only replace the app binary — your database is never modified",
@ -854,8 +831,7 @@
"Export regularly to keep a backup of your data",
"The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report",
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
"If you encounter an issue, copy the logs and attach them to your report"
]
}
},
@ -953,34 +929,5 @@
"description": "Your authentication tokens are currently stored in a local file protected by filesystem permissions. For stronger protection via the OS keychain, make sure a keyring service is running (GNOME Keyring, KWallet, or equivalent)."
}
}
},
"feedback": {
"button": "Send feedback",
"logsHeading": "Recent logs:",
"dialog": {
"title": "Your feedback",
"placeholder": "Describe your suggestion, comment, or issue...",
"submit": "Send",
"sending": "Sending...",
"cancel": "Cancel"
},
"checkbox": {
"context": "Include navigation context (page, theme, viewport, version)",
"logs": "Include recent error logs",
"identify": "Identify me with my Maximus account"
},
"toast": {
"success": "Thank you for your feedback",
"error": {
"429": "Too many feedbacks sent recently. Try again later.",
"400": "Invalid feedback. Check the content.",
"generic": "Error while sending. Try again later."
}
},
"consent": {
"title": "Feedback submission",
"body": "Your feedback will be sent to feedback.lacompagniemaximus.com so we can improve the app. This requires an internet connection and is an exception to the app's 100% local operation.",
"accept": "I agree"
}
}
}

View file

@ -358,10 +358,7 @@
"period": "Période",
"byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps",
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"trends": "Tendances mensuelles",
"budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas",
@ -400,9 +397,11 @@
"compare": "Comparables",
"compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
"categoryZoom": "Analyse par catégorie",
"categoryZoomDescription": "Zoom sur une catégorie",
"cartes": "Cartes",
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
"categoryZoomDescription": "Zoom sur une catégorie"
},
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"compare": {
"modeActual": "Réel vs réel",
@ -412,26 +411,6 @@
"subModeAria": "Période de comparaison",
"referenceMonth": "Mois de référence"
},
"cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Solde net",
"savingsRate": "Taux d'épargne",
"deltaMoMLabel": "vs mois précédent",
"deltaYoYLabel": "vs l'an dernier",
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
"topMoversUp": "Catégories en hausse",
"topMoversDown": "Catégories en baisse",
"budgetAdherenceTitle": "Respect du budget",
"budgetAdherenceSubtitle": "{{score}} des catégories avec budget sont dans la cible",
"budgetAdherenceEmpty": "Aucune catégorie avec budget ce mois-ci",
"budgetAdherenceWorst": "Pires dépassements",
"seasonalityTitle": "Saisonnalité",
"seasonalityEmpty": "Pas assez d'historique pour ce mois",
"seasonalityAverage": "Moyenne",
"seasonalityDeviation": "{{pct}} par rapport à la moyenne"
},
"category": {
"selectCategory": "Choisir une catégorie",
"includeSubcategories": "Inclure les sous-catégories",
@ -836,8 +815,7 @@
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
"Import des données depuis un fichier exporté précédemment",
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)"
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés"
],
"steps": [
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
@ -845,8 +823,7 @@
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
"Utilisez la section Gestion des données pour exporter ou importer vos données",
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",
"Cliquez sur Envoyer un feedback dans la section Journaux pour partager une suggestion, un commentaire ou un problème — la case d'identification et d'envoi du contexte/logs sont décochées par défaut"
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
],
"tips": [
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
@ -854,8 +831,7 @@
"Exportez régulièrement pour garder une sauvegarde de vos données",
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
"En cas de problème, copiez les journaux et joignez-les à votre signalement"
]
}
},
@ -953,34 +929,5 @@
"description": "Vos jetons d'authentification sont stockés dans un fichier local protégé par les permissions du système. Pour une protection renforcée via le trousseau du système d'exploitation, vérifiez que le service de trousseau est disponible (GNOME Keyring, KWallet, ou équivalent)."
}
}
},
"feedback": {
"button": "Envoyer un feedback",
"logsHeading": "Logs récents :",
"dialog": {
"title": "Votre avis",
"placeholder": "Décrivez votre suggestion, commentaire ou problème...",
"submit": "Envoyer",
"sending": "Envoi...",
"cancel": "Annuler"
},
"checkbox": {
"context": "Inclure le contexte de navigation (page, thème, écran, version)",
"logs": "Inclure les derniers logs d'erreur",
"identify": "M'identifier avec mon compte Maximus"
},
"toast": {
"success": "Merci pour votre feedback",
"error": {
"429": "Trop de feedbacks envoyés récemment. Réessayez plus tard.",
"400": "Feedback invalide. Vérifiez le contenu.",
"generic": "Erreur lors de l'envoi. Réessayez plus tard."
}
},
"consent": {
"title": "Envoi de feedback",
"body": "Votre feedback sera envoyé à feedback.lacompagniemaximus.com pour que nous puissions améliorer l'application. Cette opération nécessite une connexion Internet et constitue une exception au fonctionnement 100 % local de l'application.",
"accept": "J'accepte"
}
}
}

View file

@ -1,120 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
import { useCartes } from "../hooks/useCartes";
export default function ReportsCartesPage() {
const { t } = useTranslation();
const {
year,
month,
snapshot,
isLoading,
error,
setReferencePeriod,
period,
setPeriod,
from,
to,
setCustomDates,
} = useCartes();
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{!snapshot ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
) : (
<div className="flex flex-col gap-4">
<section
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
aria-label={t("reports.cartes.kpiSectionAria")}
>
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={snapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={snapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={snapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={snapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</section>
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
<SeasonalityCard
seasonality={snapshot.seasonality}
referenceYear={year}
referenceMonth={month}
/>
</section>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
import { Sparkles, TrendingUp, Scale, Search } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
@ -38,12 +38,6 @@ export default function ReportsPage() {
title: t("reports.hub.categoryZoom"),
description: t("reports.hub.categoryZoomDescription"),
},
{
to: `/reports/cartes${preserveSearch}`,
icon: <LayoutDashboard size={24} />,
title: t("reports.hub.cartes"),
description: t("reports.hub.cartesDescription"),
},
];
return (
@ -68,7 +62,7 @@ export default function ReportsPage() {
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.hub.explore")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{navCards.map((card) => (
<HubReportNavCard key={card.to} {...card} />
))}

View file

@ -1,20 +0,0 @@
import { describe, it, expect } from "vitest";
import { isFeedbackErrorCode } from "./feedbackService";
describe("isFeedbackErrorCode", () => {
it("recognizes the four stable error codes", () => {
expect(isFeedbackErrorCode("invalid")).toBe(true);
expect(isFeedbackErrorCode("rate_limit")).toBe(true);
expect(isFeedbackErrorCode("server_error")).toBe(true);
expect(isFeedbackErrorCode("network_error")).toBe(true);
});
it("rejects unknown strings and non-strings", () => {
expect(isFeedbackErrorCode("boom")).toBe(false);
expect(isFeedbackErrorCode("")).toBe(false);
expect(isFeedbackErrorCode(404)).toBe(false);
expect(isFeedbackErrorCode(null)).toBe(false);
expect(isFeedbackErrorCode(undefined)).toBe(false);
expect(isFeedbackErrorCode({ error: "rate_limit" })).toBe(false);
});
});

View file

@ -1,50 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
export type FeedbackErrorCode =
| "invalid"
| "rate_limit"
| "server_error"
| "network_error";
export interface FeedbackContext {
page?: string;
locale?: string;
theme?: string;
viewport?: string;
userAgent?: string;
timestamp?: string;
}
export interface FeedbackSuccess {
id: string;
created_at: string;
}
export interface SendFeedbackInput {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export async function sendFeedback(
input: SendFeedbackInput,
): Promise<FeedbackSuccess> {
return invoke<FeedbackSuccess>("send_feedback", {
content: input.content,
userId: input.userId ?? null,
context: input.context ?? null,
});
}
export async function getFeedbackUserAgent(): Promise<string> {
return invoke<string>("get_feedback_user_agent");
}
export function isFeedbackErrorCode(value: unknown): value is FeedbackErrorCode {
return (
value === "invalid" ||
value === "rate_limit" ||
value === "server_error" ||
value === "network_error"
);
}

View file

@ -1,56 +0,0 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService";
beforeAll(() => {
// Patch console.* so addEntry runs. Idempotent.
initLogCapture();
});
describe("getRecentErrorLogs", () => {
beforeEach(() => {
// Reset the in-memory buffer. clearLogs also clears sessionStorage
// which jsdom provides in vitest.
clearLogs();
});
it("returns an empty string when the log buffer is empty", () => {
expect(getRecentErrorLogs(5)).toBe("");
});
it("returns an empty string when n <= 0", () => {
console.error("boom");
expect(getRecentErrorLogs(0)).toBe("");
expect(getRecentErrorLogs(-3)).toBe("");
});
it("filters out info-level entries", () => {
// Freeze time so the ISO prefix is predictable
vi.setSystemTime(new Date("2026-04-17T15:00:00.000Z"));
console.log("just chatter");
console.warn("low fuel");
console.error("engine out");
const out = getRecentErrorLogs(10);
expect(out).not.toContain("chatter");
expect(out).toContain("WARN: low fuel");
expect(out).toContain("ERROR: engine out");
vi.useRealTimers();
});
it("keeps only the last N non-info entries in order", () => {
for (let i = 0; i < 5; i++) console.warn(`w${i}`);
const out = getRecentErrorLogs(2);
const lines = out.split("\n");
expect(lines).toHaveLength(2);
expect(lines[0]).toContain("w3");
expect(lines[1]).toContain("w4");
});
it("formats each line as `[ISO] LEVEL: message`", () => {
vi.setSystemTime(new Date("2026-04-17T15:23:45.000Z"));
console.error("export failed");
const out = getRecentErrorLogs(1);
expect(out).toMatch(/^\[2026-04-17T15:23:45\.000Z\] ERROR: export failed$/);
vi.useRealTimers();
});
});

View file

@ -86,21 +86,6 @@ export function getLogs(): readonly LogEntry[] {
return logs;
}
/// Extract the N most recent non-info entries formatted as a single string,
/// suitable for appending to a feedback body. Empty string if no qualifying
/// entries. Each line: `[ISO timestamp] LEVEL: message`.
export function getRecentErrorLogs(n: number): string {
if (n <= 0) return "";
const errors = logs.filter((l) => l.level !== "info");
const tail = errors.slice(Math.max(0, errors.length - n));
return tail
.map((l) => {
const iso = new Date(l.timestamp).toISOString();
return `[${iso}] ${l.level.toUpperCase()}: ${l.message}`;
})
.join("\n");
}
export function clearLogs() {
logs.length = 0;
saveToStorage();

View file

@ -1,229 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("shiftMonth", () => {
it("shifts forward within a year", () => {
expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
});
it("shifts backward within a year", () => {
expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
});
it("wraps around January to the previous year", () => {
expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
});
it("wraps past multiple years back", () => {
expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
});
it("wraps past year forward", () => {
expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
});
});
/**
* Dispatch mock SELECT responses based on the SQL fragment being queried.
* Each entry returns the canned rows for queries whose text contains `match`.
*/
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
mockSelect.mockImplementation((sql: string) => {
for (const r of routes) {
if (sql.includes(r.match)) return Promise.resolve(r.rows);
}
return Promise.resolve([]);
});
}
describe("getCartesSnapshot", () => {
it("returns zero-filled KPIs when there is no data", async () => {
routeSelect([]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.referenceYear).toBe(2026);
expect(snapshot.referenceMonth).toBe(3);
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(0);
expect(snapshot.kpis.net.current).toBe(0);
expect(snapshot.kpis.savingsRate.current).toBe(0);
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
expect(snapshot.flow12Months).toHaveLength(12);
expect(snapshot.topMoversUp).toHaveLength(0);
expect(snapshot.topMoversDown).toHaveLength(0);
expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
expect(snapshot.seasonality.historicalAverage).toBeNull();
expect(snapshot.seasonality.deviationPct).toBeNull();
});
it("computes MoM and YoY deltas from a monthly flow stream", async () => {
// Reference = 2026-03
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
{ month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
{ month: "2026-03", income: 5000, expenses: 2500 }, // Reference
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.income.current).toBe(5000);
expect(snapshot.kpis.income.previousMonth).toBe(4000);
expect(snapshot.kpis.income.previousYear).toBe(3000);
expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);
expect(snapshot.kpis.expenses.current).toBe(2500);
expect(snapshot.kpis.net.current).toBe(2500);
expect(snapshot.kpis.savingsRate.current).toBe(50);
});
it("January reference month shifts MoM to December of previous year", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-12", income: 2000, expenses: 1000 },
{ month: "2026-01", income: 3000, expenses: 1500 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 1);
expect(snapshot.kpis.income.current).toBe(3000);
expect(snapshot.kpis.income.previousMonth).toBe(2000);
// YoY for January 2026 = January 2025 = no data
expect(snapshot.kpis.income.previousYear).toBeNull();
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
});
it("savings rate stays at 0 when income is zero (no division by zero)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-03", income: 0, expenses: 500 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.savingsRate.current).toBe(0);
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(500);
expect(snapshot.kpis.net.current).toBe(-500);
});
it("handles less than 13 months of history by filling gaps with zero", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-03", income: 1000, expenses: 400 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
// First 12 points are zero, last one is 1000
expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
// MoM comparison with a missing month returns null (no data for 2026-02)
expect(snapshot.kpis.income.previousMonth).toBeNull();
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
});
it("computes seasonality only when historical data exists", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
},
{
match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
rows: [
{ year: 2025, amount: 1200 },
{ year: 2024, amount: 1000 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.seasonality.historicalYears).toHaveLength(2);
expect(snapshot.seasonality.historicalAverage).toBe(1100);
expect(snapshot.seasonality.referenceAmount).toBe(1500);
// (1500 - 1100) / 1100 * 100 ≈ 36.36
expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
});
it("seasonality deviation stays null when there is no historical average", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
expect(snapshot.seasonality.historicalAverage).toBeNull();
expect(snapshot.seasonality.deviationPct).toBeNull();
});
it("splits top movers by sign and caps each list at 5", async () => {
// Seven up-movers, three down-movers — verify we get 5 up and 3 down.
const momRows = [
{ category_id: 1, category_name: "C1", category_color: "#000", current_total: 200, previous_total: 100 },
{ category_id: 2, category_name: "C2", category_color: "#000", current_total: 400, previous_total: 100 },
{ category_id: 3, category_name: "C3", category_color: "#000", current_total: 500, previous_total: 100 },
{ category_id: 4, category_name: "C4", category_color: "#000", current_total: 700, previous_total: 100 },
{ category_id: 5, category_name: "C5", category_color: "#000", current_total: 900, previous_total: 100 },
{ category_id: 6, category_name: "C6", category_color: "#000", current_total: 1100, previous_total: 100 },
{ category_id: 7, category_name: "C7", category_color: "#000", current_total: 1300, previous_total: 100 },
{ category_id: 8, category_name: "D1", category_color: "#000", current_total: 100, previous_total: 500 },
{ category_id: 9, category_name: "D2", category_color: "#000", current_total: 100, previous_total: 700 },
{ category_id: 10, category_name: "D3", category_color: "#000", current_total: 100, previous_total: 900 },
];
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
},
{
// Matches the getCompareMonthOverMonth SQL pattern.
match: "ORDER BY ABS(current_total - previous_total) DESC",
rows: momRows,
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.topMoversUp).toHaveLength(5);
expect(snapshot.topMoversDown).toHaveLength(3);
// Top up is the biggest delta (C7: +1200)
expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
// Top down is the biggest negative delta (D3: -800)
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
});
});

View file

@ -1,5 +1,4 @@
import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
@ -13,15 +12,6 @@ import type {
CategoryZoomEvolutionPoint,
MonthBalance,
RecentTransaction,
CartesSnapshot,
CartesKpi,
CartesSparklinePoint,
CartesTopMover,
CartesMonthFlow,
CartesBudgetAdherence,
CartesBudgetWorstOverrun,
CartesSeasonality,
CartesSeasonalityYear,
} from "../shared/types";
export async function getMonthlyTrends(
@ -580,304 +570,3 @@ export async function getCategoryZoom(
transactions: txRows,
};
}
// --- Cartes dashboard (Issue #97) ---
/**
* Signed month shift. Exported for unit tests.
* shiftMonth(2026, 1, -1) -> { year: 2025, month: 12 }
* shiftMonth(2026, 4, -24) -> { year: 2024, month: 4 }
*/
export function shiftMonth(
year: number,
month: number,
offset: number,
): { year: number; month: number } {
const total = year * 12 + (month - 1) + offset;
return {
year: Math.floor(total / 12),
month: (total % 12) + 1,
};
}
function monthKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}`;
}
function extractDelta(
current: number,
previous: number | null,
): { abs: number | null; pct: number | null } {
if (previous === null) return { abs: null, pct: null };
const abs = current - previous;
const pct = previous === 0 ? null : (abs / previous) * 100;
return { abs, pct };
}
function buildKpi(
sparkline: CartesSparklinePoint[],
current: number,
previousMonth: number | null,
previousYear: number | null,
): CartesKpi {
const mom = extractDelta(current, previousMonth);
const yoy = extractDelta(current, previousYear);
return {
current,
previousMonth,
previousYear,
deltaMoMAbs: mom.abs,
deltaMoMPct: mom.pct,
deltaYoYAbs: yoy.abs,
deltaYoYPct: yoy.pct,
sparkline,
};
}
interface RawMonthFlow {
month: string;
income: number | null;
expenses: number | null;
}
async function fetchMonthlyFlows(
dateFrom: string,
dateTo: string,
): Promise<RawMonthFlow[]> {
const db = await getDb();
return db.select<RawMonthFlow[]>(
`SELECT
strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions
WHERE date >= $1 AND date <= $2
GROUP BY month
ORDER BY month ASC`,
[dateFrom, dateTo],
);
}
interface RawSeasonalityRow {
year: number;
amount: number | null;
}
async function fetchSeasonality(
month: number,
yearFrom: number,
yearTo: number,
): Promise<RawSeasonalityRow[]> {
const db = await getDb();
const mm = String(month).padStart(2, "0");
return db.select<RawSeasonalityRow[]>(
`SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS amount
FROM transactions
WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3
GROUP BY year
ORDER BY year DESC`,
[mm, yearFrom, yearTo],
);
}
/**
* Cartes dashboard snapshot. Single entry point that returns every widget's
* data for the Cartes report, computed against a reference (year, month).
*
* Layout (all concurrent):
* 1. 25-month expense/income series (covers ref, MoM, YoY, 12-month flow,
* 13-month sparklines without any extra round trips).
* 2. Month-over-month category deltas for top movers (existing service).
* 3. Year-over-year category deltas to seed the savings-rate YoY lookup
* via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years.
*/
export async function getCartesSnapshot(
referenceYear: number,
referenceMonth: number,
): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
const windowStart = shiftMonth(referenceYear, referenceMonth, -24);
const { start: windowStartIso } = monthBoundaries(windowStart.year, windowStart.month);
const { end: refEnd } = monthBoundaries(referenceYear, referenceMonth);
// Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
fetchMonthlyFlows(windowStartIso, refEnd),
getCompareMonthOverMonth(referenceYear, referenceMonth),
getBudgetVsActualData(referenceYear, referenceMonth),
]);
// Index the flow rows by month for O(1) lookup, then fill missing months
// with zeroes so downstream consumers get a contiguous series.
const flowByMonth = new Map<string, { income: number; expenses: number }>();
for (const r of flowRows) {
flowByMonth.set(r.month, {
income: Number(r.income ?? 0),
expenses: Number(r.expenses ?? 0),
});
}
const buildSeries = (count: number): CartesMonthFlow[] => {
const series: CartesMonthFlow[] = [];
for (let i = count - 1; i >= 0; i--) {
const { year: y, month: m } = shiftMonth(referenceYear, referenceMonth, -i);
const key = monthKey(y, m);
const row = flowByMonth.get(key);
const income = row?.income ?? 0;
const expenses = row?.expenses ?? 0;
series.push({ month: key, income, expenses, net: income - expenses });
}
return series;
};
// 13-month sparklines for each KPI (reference month + 12 prior).
const sparkSeries = buildSeries(13);
const incomeSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.income,
}));
const expensesSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.expenses,
}));
const netSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.net,
}));
const savingsSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
month: p.month,
value: p.income > 0 ? (p.net / p.income) * 100 : 0,
}));
// Compute MoM / YoY values directly from `flowByMonth` (which preserves the
// "missing" distinction). The sparkline fills gaps with zero for display,
// but deltas must remain null when the comparison month has no data.
const refKey = monthKey(referenceYear, referenceMonth);
const momMeta = shiftMonth(referenceYear, referenceMonth, -1);
const momKey = monthKey(momMeta.year, momMeta.month);
const yoyMeta = { year: referenceYear - 1, month: referenceMonth };
const yoyKey = monthKey(yoyMeta.year, yoyMeta.month);
const refRow = flowByMonth.get(refKey);
const refIncome = refRow?.income ?? 0;
const refExpenses = refRow?.expenses ?? 0;
const refNet = refIncome - refExpenses;
const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : 0;
const momRow = flowByMonth.get(momKey);
const momIncome = momRow ? momRow.income : null;
const momExpenses = momRow ? momRow.expenses : null;
const momNet = momRow ? momRow.income - momRow.expenses : null;
const momSavings =
momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;
const yoyRow = flowByMonth.get(yoyKey);
const yoyIncome = yoyRow ? yoyRow.income : null;
const yoyExpenses = yoyRow ? yoyRow.expenses : null;
const yoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
const yoySavings =
yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;
const incomeKpi = buildKpi(incomeSpark, refIncome, momIncome, yoyIncome);
const expensesKpi = buildKpi(expensesSpark, refExpenses, momExpenses, yoyExpenses);
const netKpi = buildKpi(netSpark, refNet, momNet, yoyNet);
const savingsKpi = buildKpi(savingsSpark, refSavings, momSavings, yoySavings);
// 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter(
(r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
);
const topMoversUp: CartesTopMover[] = significantMovers
.filter((r) => r.deltaAbs > 0)
.sort((a, b) => b.deltaAbs - a.deltaAbs)
.slice(0, 5);
const topMoversDown: CartesTopMover[] = significantMovers
.filter((r) => r.deltaAbs < 0)
.sort((a, b) => a.deltaAbs - b.deltaAbs)
.slice(0, 5);
// Budget adherence — only expense categories with a non-zero budget count.
// monthActual is signed from transactions; expense categories have
// monthActual <= 0, so we compare on absolute values.
const budgetedExpenseRows = budgetRows.filter(
(r) => r.category_type === "expense" && r.monthBudget > 0 && !r.is_parent,
);
const budgetsInTarget = budgetedExpenseRows.filter(
(r) => Math.abs(r.monthActual) <= r.monthBudget,
).length;
const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
.map((r) => {
const actual = Math.abs(r.monthActual);
const overrunAbs = actual - r.monthBudget;
const overrunPct = r.monthBudget > 0 ? (overrunAbs / r.monthBudget) * 100 : null;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
budget: r.monthBudget,
actual,
overrunAbs,
overrunPct,
};
})
.filter((r) => r.overrunAbs > 0)
.sort((a, b) => b.overrunAbs - a.overrunAbs)
.slice(0, 3);
const budgetAdherence: CartesBudgetAdherence = {
categoriesInTarget: budgetsInTarget,
categoriesTotal: budgetedExpenseRows.length,
worstOverruns: overruns,
};
// Seasonality — average of the same calendar month across the previous
// two years. If no data, average stays null.
const historicalYears: CartesSeasonalityYear[] = seasonalityRows.map((r) => ({
year: Number(r.year),
amount: Number(r.amount ?? 0),
}));
const historicalAverage = historicalYears.length
? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
: null;
const referenceAmount = expensesKpi.current;
const deviationPct =
historicalAverage !== null && historicalAverage > 0
? ((referenceAmount - historicalAverage) / historicalAverage) * 100
: null;
const seasonality: CartesSeasonality = {
referenceAmount,
historicalYears,
historicalAverage,
deviationPct,
};
return {
referenceYear,
referenceMonth,
kpis: {
income: incomeKpi,
expenses: expensesKpi,
net: netKpi,
savingsRate: savingsKpi,
},
flow12Months,
topMoversUp,
topMoversDown,
budgetAdherence,
seasonality,
};
}

View file

@ -360,87 +360,6 @@ export interface BudgetVsActualRow {
ytdVariationPct: number | null;
}
// --- Cartes (Issue #97) — dashboard snapshot ---
export interface CartesSparklinePoint {
month: string; // "YYYY-MM"
value: number;
}
export interface CartesKpi {
current: number;
previousMonth: number | null;
previousYear: number | null;
deltaMoMAbs: number | null;
deltaMoMPct: number | null;
deltaYoYAbs: number | null;
deltaYoYPct: number | null;
sparkline: CartesSparklinePoint[]; // 13 months ending at reference month
}
export type CartesKpiId = "income" | "expenses" | "net" | "savingsRate";
export interface CartesTopMover {
categoryId: number | null;
categoryName: string;
categoryColor: string;
previousAmount: number;
currentAmount: number;
deltaAbs: number;
deltaPct: number | null;
}
export interface CartesMonthFlow {
month: string; // "YYYY-MM"
income: number;
expenses: number;
net: number;
}
export interface CartesBudgetWorstOverrun {
categoryId: number;
categoryName: string;
categoryColor: string;
budget: number;
actual: number;
overrunAbs: number;
overrunPct: number | null;
}
export interface CartesBudgetAdherence {
categoriesInTarget: number;
categoriesTotal: number;
worstOverruns: CartesBudgetWorstOverrun[];
}
export interface CartesSeasonalityYear {
year: number;
amount: number;
}
export interface CartesSeasonality {
referenceAmount: number;
historicalYears: CartesSeasonalityYear[]; // up to 2 previous years
historicalAverage: number | null;
deviationPct: number | null;
}
export interface CartesSnapshot {
referenceYear: number;
referenceMonth: number;
kpis: {
income: CartesKpi;
expenses: CartesKpi;
net: CartesKpi;
savingsRate: CartesKpi; // value stored as 0-100
};
flow12Months: CartesMonthFlow[];
topMoversUp: CartesTopMover[];
topMoversDown: CartesTopMover[];
budgetAdherence: CartesBudgetAdherence;
seasonality: CartesSeasonality;
}
export type ImportWizardStep =
| "source-list"
| "source-config"