Aurora HomeAurora HomeDocs
DocsConventions App

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.ts
React 19Server Components + Client Components ('use client')
TypeScript 5Mode strict activé — tous les types explicites
Tailwind CSS v4Config via globals.css uniquement (pas de tailwind.config.js)
Better AuthAuthentification sans mot de passe — plugin emailOTP
Prisma + SQLiteORM — schéma dans prisma/schema.prisma
next-intli18n — messages/fr.json et messages/en.json
BiomeLinting + formatting (remplace ESLint + Prettier)
VitestTests unitaires — config dans vitest.config.ts
RechartsGraphiques — AreaChart avec gradient blanc
shadcn/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éterministes

Structure des dossiers

aurora-home-app/
aurora-home-app/
app/# Next.js App Router
api/
auth/
[...all]# Better Auth catch-all
sensor-stream# SSE temps réel
locale# Préférence langue
datapoints# Historique capteurs + période
thresholds# Seuils personnalisés
preferences# Préférences notification
(connected)/# Routes protégées
layout.tsx# Vérification session
page.tsx# Dashboard principal
auth/
login
otp
profile
docs
settings# Seuils & préférences alerte
features/# Modules fonctionnels (Domain-Driven)
auth/
components# LoginForm, OtpForm
usecase# login, signInOtp, signOut
repository# sessionRepository
datapoint/
components# ChartDatapoint, DatapointItem, DashboardDatapoints, IaqScore
usecase# getInitialDataPoints
repository# dataPointRepository, thresholdRepository, preferenceRepository
utils# calculateChartDomain, toDataPoints, calculateIaq, downsample
profile/
components# ProfileCard, AvatarSelector, EditableFields, SignOutButton
hooks# useProfileSubmit
repository# userRepository
usecase# getUserProfile, updateUserProfile
utils# profileSchema (Zod)
lib/# Utilitaires partagés
auth.ts# Config Better Auth
auth-client.ts# Méthodes client
mqtt-client.ts# Client MQTT + parsing
sensor-emitter.ts# EventEmitter interne
prisma.ts# Client Prisma singleton
otp-display.ts# Affichage OTP sur I2C / terminal
usecase.ts# HOF wrapper usecase
utils.ts# Helpers (cn, etc.)
components/
ui# shadcn/ui (Radix UI)
specific# Composants métier (ButtonForm, etc.)
hooks/
useAnimatedValue.ts# Animation numérique easeOutCubic
useSensorData.ts# Hook SSE temps réel
useTrend.ts# Direction de tendance par capteur
messages/
fr.json# Traductions françaises
en.json# Traductions anglaises
prisma/
schema.prisma# Schéma base de données
seedFakeData.ts# Données de test Faker.js
seed7days.ts# 7 jours de données réalistes (1pt/5min)
clearData.ts# Vider toutes les tables

Conventions de nommage

Fichierskebab-case
mqtt-client.tssensor-emitter.tsdatapoint-item.tsxuse-sensor-data.ts
Composants ReactPascalCase
ChartDatapoint.tsxLoginForm.tsxProfileSheet.tsxAvatarSelector.tsx
Fonctions & VariablescamelCase
parseNumericValue()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>.

lib/usecase.ts
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 };
Utilisation — Server Action
"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.

features/datapoint/repository/dataPointRepository.ts
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 } }),
};
features/profile/repository/userRepository.ts
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.

features/datapoint/utils/parseNumericValue.ts
// 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;
}
features/datapoint/utils/toDataPoints.ts
// 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),
  }));
}
features/datapoint/utils/calculateChartDomain.ts
// 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

features/auth/usecase/login.ts — Schéma Zod
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
features/auth/usecase/signInOtp.ts — Vérification OTP
// 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

features/profile/usecase/updateUserProfile.ts — Schéma Zod
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

hooks/useAnimatedValue.ts
// 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'affichage
hooks/useSensorData.ts
const 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() + clearTimeout

Affichage 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).
Sortie console DISPLAY_OTP_DEV_MODE=true
╔════════════════════════════════════╗
║  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 :

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

strictNullChecks

null 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.name
noImplicitAny

Interdit les types any implicites. Tout paramètre de fonction doit avoir un type explicite.

function fn(x: number) {} // pas function fn(x) {}
strictFunctionTypes

Vérifie la contravariance des paramètres de fonctions — évite les erreurs de type dans les callbacks.

strictPropertyInitialization

Les propriétés de classe doivent être initialisées dans le constructeur ou déclarées avec !.

noImplicitThis

Interdit l'usage de this implicitement typé any.

alwaysStrict

Injecte "use strict" dans chaque fichier compilé.

Règles de style TypeScript appliquées dans le projet

import type

Utiliser 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 explicite

Le type any est banni. Utiliser unknown puis narrowing, ou un type précis.

// ❌ (data: any) ✓ (data: unknown)
Types de retour implicites

Les 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";
isolatedModules

Chaque 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:watch

Nouveaux hooks

hooks/useTrend.ts
// 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.