Conventions — Application Next.js
Standards de code et d'organisation adoptés dans le projet aurora-home-app.
Stack technique
Next.js 15App Router, Server Components, Route Handlers, instrumentation.tsReact 19Server Components + Client Components ('use client')TypeScript 5Mode strict activé — tous les types explicitesTailwind CSS v4Config via globals.css uniquement (pas de tailwind.config.js)Better AuthAuthentification sans mot de passe — plugin emailOTPPrisma + SQLiteORM — schéma dans prisma/schema.prismanext-intli18n — messages/fr.json et messages/en.jsonBiomeLinting + formatting (remplace ESLint + Prettier)VitestTests unitaires — config dans vitest.config.tsRechartsGraphiques — AreaChart avec gradient blancshadcn/ui + Radix UIComposants UI accessibles dans components/ui/react-hook-form + ZodFormulaires validés côté client et serveur@dicebear/collectionGénération d'avatars SVG déterministesStructure des dossiers
Conventions de nommage
kebab-casemqtt-client.tssensor-emitter.tsdatapoint-item.tsxuse-sensor-data.tsPascalCaseChartDatapoint.tsxLoginForm.tsxProfileSheet.tsxAvatarSelector.tsxcamelCaseparseNumericValue()getInitialDataPoints()sensorEmittertoDataPoints()Pattern Usecase
Toute la logique métier est encapsulée dans des usecases. Le wrapper usecase(fn) dans lib/usecase.ts enveloppe automatiquement la fonction dans un try/catch et retourne un type uniforme UsecaseResult<T>.
export default function usecase<TArgs, TResult>(
fn: (args: TArgs) => Promise<TResult>
) {
return async (args: TArgs): Promise<UsecaseResult<TResult>> => {
try {
const data = await fn(args);
return { success: true, data };
} catch (error) {
return { success: false, error: String(error) };
}
};
}
type UsecaseResult<T> =
| { success: true; data: T }
| { success: false; error: string };"use server";
import usecase from "@/lib/usecase";
const getInitialDataPoints = usecase(async () => {
return await dataPointRepository.findLatestAll();
});
// Côté composant
const result = await getInitialDataPoints({});
if (result.success) {
console.log(result.data); // typé
} else {
console.error(result.error); // string
}Tous les usecases sont des Server Actions ( ("use server").). Ils ne retournent jamais une exception — l'erreur est toujours capturée dans result.error.
Pattern Repository
Tous les accès Prisma passent par des repositories. Pas d'appel direct à prisma.* en dehors des repositories.
export const dataPointRepository = {
findLatestByType: (type: DataType, take = 20) =>
prisma.dataPoint.findMany({
where: { type },
orderBy: { createdAt: "desc" },
take,
}),
findLatestAll: () =>
Promise.all(
DATA_TYPES.map((type) =>
dataPointRepository.findLatestByType(type)
)
),
create: (type: DataType, value: string) =>
prisma.dataPoint.create({ data: { type, value } }),
};export const userRepository = {
findById: (id: string) =>
prisma.user.findUnique({ where: { id } }),
findByEmail: (email: string) =>
prisma.user.findUnique({ where: { email } }),
update: (id: string, data: Partial<User>) =>
prisma.user.update({ where: { id }, data }),
};Server vs Client Components
Server Components (défaut)
- Pages et layouts (pas de
"use client") - Accès direct à Prisma,
auth.api.*, variables d'environnement - Pas de hooks React ( (
useState,useEffect) - Exemples :
app/(connected)/page.tsx,ProfileSheetProvider
Client Components
- Marqués
"use client"en première ligne - Composants avec hooks React, event listeners, SSE, animations
- Exemples :
ChartDatapoint,LoginForm,ProfileCard,AvatarSelector
Utilitaires datapoint
L'ESP32 envoie des valeurs brutes avec unité (ex : ("22.50 °C").). Ces fonctions font le pont entre la base et les graphiques.
// Extrait la partie numérique d'une chaîne "22.50 °C" → 22.50
export function parseNumericValue(value: string): number {
const match = value.match(/^([d.]+)/);
return match ? parseFloat(match[1]) : 0;
}// Convertit SerializedDataPoint (createdAt: string) → DataPoint (createdAt: Date)
// Utilisé dans DashboardDatapoints avant de passer aux cartes enfants
export function toDataPoints(serialized: SerializedDataPoint[]): DataPoint[] {
return serialized.map((dp) => ({
...dp,
createdAt: new Date(dp.createdAt),
}));
}// Domaine Y du graphique par type
// Paramètres : type DataType, values: number[]
// Retour : [min: number, max: number] | ["auto", "auto"]
TEMPERATURE : [Math.floor(min - margin), Math.ceil(max + margin)]
Constante → [value - 2, value + 2]
Défaut → [0, 30]
HUMIDITY : [Math.max(0, floor(min - margin)), Math.min(100, ceil(max + margin))]
Constante → [value - 1, value + 1] clampé [0, 100]
Défaut → [0, 100]
PRESSURE : [Math.min(950, floor(min - margin)), Math.max(1050, ceil(max + margin))]
Constante → [value - 10, value + 10]
Défaut → [950, 1050]
CO2 : [Math.max(300, floor(min - margin)), ceil(max + margin)]
Constante → [value - 1, value + 1]
Défaut → [300, 2000]
LIGHT : [Math.max(0, floor(min - margin)), ceil(max + margin)]
Constante → [value - 1, value + 1]
Défaut → [0, 1000]
// margin = (max - min) * 0.1 (10% de la plage)Flux d'authentification OTP
const loginSchema = z.object({
email: z.string().email(),
name: z.string().optional(), // Optionnel — prénom pour premier compte
});
// Comportement
// 1. authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" })
// 2. store.set("otp_email", email) // Cookie session
// 3. store.set("otp_name", name) // Cookie si prénom fourni
// 4. Redirige vers /auth/otp?type=sign-in// 1. Récupère email depuis cookie "otp_email"
// 2. auth.api.signInEmailOTP({ body: { email, otp } })
// 3. store.delete("otp_email") + store.delete("otp_name")
// 4. clearScreen() — efface l'écran OTP I2C si actif
// Génération avatar automatique à la première connexion
if (!user.image) {
updates.image = createAvatar(adventurer, {
seed: user.email || user.id, // seed déterministe
size: 128,
}).toDataUri(); // data:image/svg+xml;base64,...
}
// Extraction du nom depuis l'email si non défini
// "jean.dupont@example.com" → "Jean"
const extractNameFromEmail = (email: string): string => {
const beforeAt = email.split("@")[0]; // "jean.dupont"
const name = beforeAt.split(".")[0]; // "jean"
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); // "Jean"
};Mise à jour du profil
const updateProfileSchema = z.object({
name: z.string().min(1, "Le nom est requis"),
email: z.string().email().min(1),
image: z.union([
z.string().refine(
(val) =>
val.trim().length === 0 ||
val.startsWith("data:") || // data URI (DiceBear SVG)
val.startsWith("http://") ||
val.startsWith("https://"),
{ message: "URL valide ou data URI requis" }
),
z.null(),
]).optional().nullable()
.transform((val) => (val === "" || val === null ? null : val)),
});
// Vérifications côté serveur
// 1. auth.api.getSession({ headers }) → session requise
// 2. updateProfileSchema.parse(data) → validation
// 3. userRepository.findByEmail(email) → unicité email
// 4. userRepository.update(session.user.id, { name, email, image })Hooks partagés
// Anime une valeur numérique vers une cible avec easeOutCubic
useAnimatedValue(targetValue: number, duration: number = 800): number
function easeOutCubic(t: number): number {
return 1 - (1 - t) ** 3;
}
// Comportement
// - requestAnimationFrame — pas de setInterval
// - Anime depuis la valeur précédente vers targetValue
// - cancelAnimationFrame au unmount (cleanup)
// - Si from === to : aucune animation, retourne la valeur directement
// - Résultat : utiliser .toFixed(2) pour l'affichageconst MAX_POINTS_PER_TYPE = 20;
// Signature
useSensorData(
initialData: Record<DataType, SerializedDataPoint[]>
): Record<DataType, SerializedDataPoint[]>
// Connexion SSE
const es = new EventSource("/api/sensor-stream");
// Message type "sensor_update" reçu
es.onmessage = (event) => {
const msg: SensorUpdate = JSON.parse(event.data);
if (msg.type === "sensor_update") {
setData((prev) => ({
...prev,
[key]: [newPoint, ...prev[key]].slice(0, MAX_POINTS_PER_TYPE),
}));
}
};
// Reconnexion automatique sur erreur — délai 3000ms
es.onerror = () => {
es.close();
setTimeout(connect, 3000);
};
// Cleanup au unmount : es.close() + clearTimeoutAffichage OTP — lib/otp-display.ts
Deux modes d'affichage du code OTP contrôlés par des variables d'environnement :
DISPLAY_OTP_DEV_MODE=trueAffiche le code OTP dans la console Next.js (ASCII art). Aucun matériel requis — pour le développement local.DISPLAY_OTP_ENABLED=trueEnvoie le code à l'écran I2C physique via un script Node.js (Linux uniquement, Orange Pi).╔════════════════════════════════════╗
║ AuroraHome ║
║ Auth. code : ║
║ ║
║ 483921 ║
║ ║
║ Exp: 5 min ║
╚════════════════════════════════════╝Configuration TypeScript
Le mode strict est activé. Voici les règles effectives qui en découlent et les autres options importantes du tsconfig.json :
{
"compilerOptions": {
"target": "ES2017",
"strict": true, // Active toutes les vérifications strictes
"noEmit": true, // Next.js gère la compilation — TS vérifie seulement
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"isolatedModules": true, // Chaque fichier est un module indépendant
"resolveJsonModule": true,
"jsx": "react-jsx",
"incremental": true,
"paths": { "@/*": ["./*"] } // Alias import @/
}
}Ce que strict: true impose
strictNullChecksnull et undefined ne sont pas assignables aux autres types. Il faut explicitement gérer les cas null.
const name: string = user.name ?? "" // pas juste user.namenoImplicitAnyInterdit les types any implicites. Tout paramètre de fonction doit avoir un type explicite.
function fn(x: number) {} // pas function fn(x) {}strictFunctionTypesVérifie la contravariance des paramètres de fonctions — évite les erreurs de type dans les callbacks.
strictPropertyInitializationLes propriétés de classe doivent être initialisées dans le constructeur ou déclarées avec !.
noImplicitThisInterdit l'usage de this implicitement typé any.
alwaysStrictInjecte "use strict" dans chaque fichier compilé.
Règles de style TypeScript appliquées dans le projet
import typeUtiliser import type pour les imports de types uniquement. Réduit le bundle et clarifie les dépendances.
import type { User } from "@prisma/client";Pas de any expliciteLe type any est banni. Utiliser unknown puis narrowing, ou un type précis.
// ❌ (data: any) ✓ (data: unknown)Types de retour implicitesLes fonctions simples peuvent laisser TypeScript inférer le type de retour. Les usecases et repositories ont des types de retour explicites.
Alias @/*Tous les imports internes utilisent l'alias @/ (racine du projet). Pas de chemins relatifs ../../../.
import usecase from "@/lib/usecase";isolatedModulesChaque fichier .ts/.tsx doit avoir au moins un import ou export pour être un module ES. Requis pour la compatibilité Babel/SWC.
Tests & Qualité du code
pour le linting et le formatting (remplace ESLint + Prettier). pour les tests unitaires.
# Vérifier le code (lint + format)
npx biome check .
# Corriger automatiquement
npx biome check --write .
# Lancer les tests
npm run test
# Tests en mode watch
npm run test:watchNouveaux hooks
// Signature
useTrend(type: DataType, values: number[]): "up" | "down" | "stable"
// Seuils de stabilité par type
// TEMPERATURE : ± 0.5
// HUMIDITY : ± 1
// PRESSURE : ± 0.5
// CO2 : ± 20
// LIGHT : ± 30
// Comportement
// 1. Calcule la moyenne des 3 valeurs précédentes (values[1..3])
// 2. Compare values[0] (la plus récente) à cette moyenne
// 3. Si delta > seuil → "up"
// 4. Si delta < -seuil → "down"
// 5. Sinon → "stable"Calcule la direction de tendance pour un type de capteur donné en comparant la dernière valeur à la moyenne des 3 relevés précédents. Retourne "up", "down" ou "stable" selon les seuils de stabilité définis par type.