feat: feedback hub widget in Settings Logs card #100
18 changed files with 847 additions and 22 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## [Non publié]
|
||||
|
||||
### 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é
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 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
|
||||
|
|
@ -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
|
||||
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. 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
|
||||
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
|
||||
|
||||
### 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
|
||||
- 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 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
|
||||
|
|
|
|||
159
src-tauri/src/commands/feedback_commands.rs
Normal file
159
src-tauri/src/commands/feedback_commands.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
@ -10,6 +11,7 @@ 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::*;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ 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");
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
}
|
||||
],
|
||||
"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": {
|
||||
|
|
|
|||
67
src/components/settings/FeedbackConsentDialog.tsx
Normal file
67
src/components/settings/FeedbackConsentDialog.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
239
src/components/settings/FeedbackDialog.tsx
Normal file
239
src/components/settings/FeedbackDialog.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,37 @@
|
|||
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
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 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);
|
||||
|
|
@ -54,6 +75,13 @@ 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}
|
||||
|
|
@ -111,6 +139,14 @@ export default function LogViewerCard() {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{consentOpen && (
|
||||
<FeedbackConsentDialog
|
||||
onAccept={acceptConsent}
|
||||
onCancel={() => setConsentOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
44
src/hooks/useFeedback.test.ts
Normal file
44
src/hooks/useFeedback.test.ts
Normal 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
68
src/hooks/useFeedback.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -358,7 +358,10 @@
|
|||
"period": "Period",
|
||||
"byCategory": "Expenses by Category",
|
||||
"overTime": "Category Over Time",
|
||||
"trends": "Monthly Trends",
|
||||
"trends": {
|
||||
"subviewGlobal": "Global flow",
|
||||
"subviewByCategory": "By category"
|
||||
},
|
||||
"budgetVsActual": "Budget vs Actual",
|
||||
"subtotalsOnTop": "Subtotals on top",
|
||||
"subtotalsOnBottom": "Subtotals on bottom",
|
||||
|
|
@ -401,10 +404,6 @@
|
|||
"cartes": "Cards",
|
||||
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
|
||||
},
|
||||
"trends": {
|
||||
"subviewGlobal": "Global flow",
|
||||
"subviewByCategory": "By category"
|
||||
},
|
||||
"compare": {
|
||||
"modeActual": "Actual vs actual",
|
||||
"modeBudget": "Actual vs budget",
|
||||
|
|
@ -837,7 +836,8 @@
|
|||
"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 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": [
|
||||
"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",
|
||||
"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"
|
||||
"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": [
|
||||
"Updates only replace the app binary — your database is never modified",
|
||||
|
|
@ -853,7 +854,8 @@
|
|||
"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"
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,7 +358,10 @@
|
|||
"period": "Période",
|
||||
"byCategory": "Dépenses par catégorie",
|
||||
"overTime": "Catégories dans le temps",
|
||||
"trends": "Tendances mensuelles",
|
||||
"trends": {
|
||||
"subviewGlobal": "Flux global",
|
||||
"subviewByCategory": "Par catégorie"
|
||||
},
|
||||
"budgetVsActual": "Budget vs Réel",
|
||||
"subtotalsOnTop": "Sous-totaux en haut",
|
||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||
|
|
@ -401,10 +404,6 @@
|
|||
"cartes": "Cartes",
|
||||
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
|
||||
},
|
||||
"trends": {
|
||||
"subviewGlobal": "Flux global",
|
||||
"subviewByCategory": "Par catégorie"
|
||||
},
|
||||
"compare": {
|
||||
"modeActual": "Réel vs réel",
|
||||
"modeBudget": "Réel vs budget",
|
||||
|
|
@ -837,7 +836,8 @@
|
|||
"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"
|
||||
"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": [
|
||||
"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",
|
||||
"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"
|
||||
"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": [
|
||||
"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",
|
||||
"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"
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/services/feedbackService.test.ts
Normal file
20
src/services/feedbackService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
src/services/feedbackService.ts
Normal file
50
src/services/feedbackService.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
56
src/services/logService.test.ts
Normal file
56
src/services/logService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -86,6 +86,21 @@ 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue