Compare commits

...

4 commits

Author SHA1 Message Date
le king fu
4416457c22 chore: release v0.8.2
All checks were successful
Release / build-and-release (push) Successful in 24m6s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:41:48 -04:00
4f4ab87bea feat: feedback hub widget in Settings Logs card (#67)
Closes #67

Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:36:26 +00:00
le king fu
3b2587d843 chore: bump version to 0.8.1
All checks were successful
Release / build-and-release (push) Successful in 24m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:07:53 -04:00
89b69f325e feat: new Cartes dashboard report — KPI cards, sparklines, top movers (#97)
Closes #97
2026-04-15 23:53:37 +00:00
21 changed files with 855 additions and 26 deletions

View file

@ -2,7 +2,10 @@
## [Non publié] ## [Non publié]
## [0.8.2] - 2026-04-17
### Ajouté ### 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) - **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é ### Modifié

View file

@ -2,7 +2,10 @@
## [Unreleased] ## [Unreleased]
## [0.8.2] - 2026-04-17
### Added ### 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) - **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 ### Changed

View file

@ -309,6 +309,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres - Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic - Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement - 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 - 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 - Import des données depuis un fichier exporté précédemment
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés - Chiffrement AES-256-GCM optionnel pour les fichiers exportés
@ -318,9 +319,10 @@ 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 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 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 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Utilisez la section Gestion des données pour exporter ou importer vos données 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. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement 5. Utilisez la section Gestion des données pour exporter ou importer vos données
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe 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
### Astuces ### Astuces
@ -329,4 +331,5 @@ 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 - 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 - 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 - 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 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

View file

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

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

@ -0,0 +1,159 @@
// 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,6 +2,7 @@ pub mod account_cache;
pub mod auth_commands; pub mod auth_commands;
pub mod entitlements; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_commands; pub mod profile_commands;
@ -10,6 +11,7 @@ pub mod token_store;
pub use auth_commands::*; pub use auth_commands::*;
pub use entitlements::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*; pub use fs_commands::*;
pub use license_commands::*; pub use license_commands::*;
pub use profile_commands::*; pub use profile_commands::*;

View file

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

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat", "productName": "Simpl Resultat",
"version": "0.8.0", "version": "0.8.2",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -18,7 +18,7 @@
} }
], ],
"security": { "security": {
"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:" "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:"
} }
}, },
"bundle": { "bundle": {

View file

@ -0,0 +1,67 @@
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

@ -0,0 +1,239 @@
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,16 +1,37 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react"; import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check } from "lucide-react"; import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService"; import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel; type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() { export default function LogViewerCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all"); const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null); 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 logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter); const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
@ -54,6 +75,13 @@ export default function LogViewerCard() {
{t("settings.logs.title")} {t("settings.logs.title")}
</h2> </h2>
<div className="flex items-center gap-2"> <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 <button
onClick={handleCopy} onClick={handleCopy}
disabled={filtered.length === 0} disabled={filtered.length === 0}
@ -111,6 +139,14 @@ export default function LogViewerCard() {
)) ))
)} )}
</div> </div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div> </div>
); );
} }

View file

@ -0,0 +1,44 @@
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);
});
});

68
src/hooks/useFeedback.ts Normal file
View file

@ -0,0 +1,68 @@
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,7 +358,10 @@
"period": "Period", "period": "Period",
"byCategory": "Expenses by Category", "byCategory": "Expenses by Category",
"overTime": "Category Over Time", "overTime": "Category Over Time",
"trends": "Monthly Trends", "trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
@ -401,10 +404,6 @@
"cartes": "Cards", "cartes": "Cards",
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality" "cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
}, },
"trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"compare": { "compare": {
"modeActual": "Actual vs actual", "modeActual": "Actual vs actual",
"modeBudget": "Actual vs budget", "modeBudget": "Actual vs budget",
@ -837,7 +836,8 @@
"Application logs viewable with level filters, copy, and clear", "Application logs viewable with level filters, copy, and clear",
"Data export (transactions, categories, or both) in JSON or CSV format", "Data export (transactions, categories, or both) in JSON or CSV format",
"Data import from a previously exported file", "Data import from a previously exported file",
"Optional AES-256-GCM encryption for exported files" "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)"
], ],
"steps": [ "steps": [
"Click User Guide to access the full documentation", "Click User Guide to access the full documentation",
@ -845,7 +845,8 @@
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear", "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", "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 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" "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"
], ],
"tips": [ "tips": [
"Updates only replace the app binary — your database is never modified", "Updates only replace the app binary — your database is never modified",
@ -853,7 +854,8 @@
"Export regularly to keep a backup of your data", "Export regularly to keep a backup of your data",
"The user guide can be printed or exported to PDF via the Print button", "The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh", "Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report" "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"
] ]
} }
}, },
@ -951,5 +953,34 @@
"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)." "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,7 +358,10 @@
"period": "Période", "period": "Période",
"byCategory": "Dépenses par catégorie", "byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps", "overTime": "Catégories dans le temps",
"trends": "Tendances mensuelles", "trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"budgetVsActual": "Budget vs Réel", "budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
@ -401,10 +404,6 @@
"cartes": "Cartes", "cartes": "Cartes",
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité" "cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
}, },
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"compare": { "compare": {
"modeActual": "Réel vs réel", "modeActual": "Réel vs réel",
"modeBudget": "Réel vs budget", "modeBudget": "Réel vs budget",
@ -837,7 +836,8 @@
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement", "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", "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", "Import des données depuis un fichier exporté précédemment",
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés" "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)"
], ],
"steps": [ "steps": [
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète", "Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
@ -845,7 +845,8 @@
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez", "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", "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'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" "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"
], ],
"tips": [ "tips": [
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée", "Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
@ -853,7 +854,8 @@
"Exportez régulièrement pour garder une sauvegarde de vos données", "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", "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", "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" "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"
] ]
} }
}, },
@ -951,5 +953,34 @@
"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)." "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

@ -0,0 +1,20 @@
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

@ -0,0 +1,50 @@
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

@ -0,0 +1,56 @@
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,6 +86,21 @@ export function getLogs(): readonly LogEntry[] {
return logs; 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() { export function clearLogs() {
logs.length = 0; logs.length = 0;
saveToStorage(); saveToStorage();