add pg + todo ok

This commit is contained in:
ordinarthur 2026-04-16 10:46:51 +02:00
parent 9c93e74318
commit f851da4677
8 changed files with 142 additions and 56 deletions

View File

@ -123,6 +123,7 @@ theme: {
### Règles visuelles ### Règles visuelles
- Le produit reste un outil personnel avant tout : clarté d'usage et vitesse priment sur la démonstration visuelle
- Bordures partout : sections, grid cells, nav - Bordures partout : sections, grid cells, nav
- Pas d'ombre portée (shadow-none) - Pas d'ombre portée (shadow-none)
- Imagery grayscale par défaut - Imagery grayscale par défaut

12
PLAN.md
View File

@ -9,7 +9,7 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
- Dashboard clair de ce qu'il fait / veut faire - Dashboard clair de ce qu'il fait / veut faire
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic - Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
- Pas de "weekly review" automatique, pas de nudges - Pas de "weekly review" automatique, pas de nudges
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → transcription → création d'une todo / idée / étape projet / événement agenda, avec validation explicite avant écriture en base - Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → Groq Whisper → Mistral function calling → création/enrichissement de todos, idées ou étapes projet, avec validation explicite avant écriture en base. **C'est le cœur du produit.** Les formulaires manuels sont intentionnellement minimalistes.
## Principes directeurs ## Principes directeurs
@ -57,11 +57,11 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`) - Rétention : jobs >30j auto-archivés (soft delete via `archived=true`)
- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h - Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h
### Phase 2 — Todos riches ### Phase 2 — Todos (capture rapide)
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet) - Migration : table `todos`
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation) - API : CRUD complet (schéma riche pour que la Phase 5 puisse tout remplir via function calling)
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline - PWA : **UI volontairement minimale** — textarea de capture + liste plate (checkbox done / delete). Pas de filtres, pas de formulaire de métadonnées. La priorité, le contexte, les tags seront remplis par le **magic button vocal (Phase 5)**.
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations` - Les champs riches (priority, context, tags, due_at, checklist…) restent dans le schéma DB et l'API pour la Phase 5.
### Phase 3 — Projets + Kanban ### Phase 3 — Projets + Kanban
- Migrations : `projects`, `project_steps`, `project_ideas` - Migrations : `projects`, `project_steps`, `project_ideas`

View File

@ -5,9 +5,10 @@ import { HealthModule } from "./modules/health/health.module";
import { AuthModule } from "./modules/auth/auth.module"; import { AuthModule } from "./modules/auth/auth.module";
import { BearerMiddleware } from "./modules/auth/bearer.middleware"; import { BearerMiddleware } from "./modules/auth/bearer.middleware";
import { JobsModule } from "./modules/jobs/jobs.module"; import { JobsModule } from "./modules/jobs/jobs.module";
import { TodosModule } from "./modules/todos/todos.module";
@Module({ @Module({
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule], imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@ -1,6 +1,8 @@
import { z } from "zod";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { z } from "zod";
const EnvSchema = z.object({ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
@ -14,6 +16,7 @@ const EnvSchema = z.object({
// car Drizzle préfixe lui-même le schéma (`pgSchema`). // car Drizzle préfixe lui-même le schéma (`pgSchema`).
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
// Phase 5+ — optionnels jusque-là
MISTRAL_API_KEY: z.string().optional(), MISTRAL_API_KEY: z.string().optional(),
MISTRAL_MODEL: z.string().default("mistral-small-latest"), MISTRAL_MODEL: z.string().default("mistral-small-latest"),
GROQ_API_KEY: z.string().optional(), GROQ_API_KEY: z.string().optional(),
@ -34,53 +37,12 @@ let cached: AppConfig | null = null;
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
if (cached) return cached; if (cached) return cached;
loadEnvFile();
const parsed = EnvSchema.safeParse(process.env); const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) { if (!parsed.success) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors); console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
throw new Error("Invalid environment configuration"); throw new Error("Invalid environment configuration");
} }
cached = parsed.data; cached = parsed.data;
return cached; return cached;
} }
function loadEnvFile() {
const candidates = [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "apps/api/.env"),
path.resolve(__dirname, "../../.env"),
];
const envPath = candidates.find((candidate) => existsSync(candidate));
if (!envPath) return;
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (!key || process.env[key] !== undefined) continue;
process.env[key] = stripWrappingQuotes(value);
}
}
function stripWrappingQuotes(value: string) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}

View File

@ -9,10 +9,16 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TodosRouteImport } from './routes/todos'
import { Route as JobsRouteImport } from './routes/jobs' import { Route as JobsRouteImport } from './routes/jobs'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs' import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
const TodosRoute = TodosRouteImport.update({
id: '/todos',
path: '/todos',
getParentRoute: () => rootRouteImport,
} as any)
const JobsRoute = JobsRouteImport.update({ const JobsRoute = JobsRouteImport.update({
id: '/jobs', id: '/jobs',
path: '/jobs', path: '/jobs',
@ -32,35 +38,46 @@ const SettingsJobsRoute = SettingsJobsRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/jobs': typeof JobsRoute '/jobs': typeof JobsRoute
'/todos': typeof TodosRoute
'/settings/jobs': typeof SettingsJobsRoute '/settings/jobs': typeof SettingsJobsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/jobs': typeof JobsRoute '/jobs': typeof JobsRoute
'/todos': typeof TodosRoute
'/settings/jobs': typeof SettingsJobsRoute '/settings/jobs': typeof SettingsJobsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/jobs': typeof JobsRoute '/jobs': typeof JobsRoute
'/todos': typeof TodosRoute
'/settings/jobs': typeof SettingsJobsRoute '/settings/jobs': typeof SettingsJobsRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/jobs' | '/settings/jobs' fullPaths: '/' | '/jobs' | '/todos' | '/settings/jobs'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/jobs' | '/settings/jobs' to: '/' | '/jobs' | '/todos' | '/settings/jobs'
id: '__root__' | '/' | '/jobs' | '/settings/jobs' id: '__root__' | '/' | '/jobs' | '/todos' | '/settings/jobs'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
JobsRoute: typeof JobsRoute JobsRoute: typeof JobsRoute
TodosRoute: typeof TodosRoute
SettingsJobsRoute: typeof SettingsJobsRoute SettingsJobsRoute: typeof SettingsJobsRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/todos': {
id: '/todos'
path: '/todos'
fullPath: '/todos'
preLoaderRoute: typeof TodosRouteImport
parentRoute: typeof rootRouteImport
}
'/jobs': { '/jobs': {
id: '/jobs' id: '/jobs'
path: '/jobs' path: '/jobs'
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
JobsRoute: JobsRoute, JobsRoute: JobsRoute,
TodosRoute: TodosRoute,
SettingsJobsRoute: SettingsJobsRoute, SettingsJobsRoute: SettingsJobsRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@ -17,6 +17,7 @@ function RootLayout() {
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
<NavLink to="/">Dashboard</NavLink> <NavLink to="/">Dashboard</NavLink>
<NavLink to="/jobs">Jobs</NavLink> <NavLink to="/jobs">Jobs</NavLink>
<NavLink to="/todos">Todos</NavLink>
</nav> </nav>
</div> </div>
</header> </header>

View File

@ -72,8 +72,8 @@ function Dashboard() {
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Label prefix="[ 03 ]">PRIORITÉ</Label> <Label prefix="[ 03 ]">PRIORITÉ</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink"> <p className="max-w-xl font-sans text-sm leading-6 text-ink">
La Phase 1 est la première verticale métier: ingestion backend, listing éditorial, Les verticales métier arrivent les unes après les autres: jobs pour la veille,
filtres de recherche et critères intégrés directement dans la vue jobs. puis todos pour piloter lexécution au quotidien.
</p> </p>
<a <a
href="/jobs" href="/jobs"
@ -85,9 +85,15 @@ function Dashboard() {
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Label prefix="[ 04 ]">MODE</Label> <Label prefix="[ 04 ]">MODE</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink"> <p className="max-w-xl font-sans text-sm leading-6 text-ink">
Les presets de critères vivent maintenant dans le header de la page jobs pour rester Loutil reste mono-utilisateur, simple, et chaque action IA continue de passer par une
au plus près du tri quotidien. confirmation explicite avant écriture.
</p> </p>
<a
href="/todos"
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
>
Ouvrir les todos
</a>
</div> </div>
</GridFrame> </GridFrame>
</div> </div>

View File

@ -113,6 +113,103 @@ export const JobSearchCriteriaUpsert = z.object({
}); });
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>; export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
// ---------------------------------------------------------------------------
// Phase 2 — Todos
// ---------------------------------------------------------------------------
export const TodoStatus = z.enum(["inbox", "todo", "doing", "done", "archived"]);
export type TodoStatus = z.infer<typeof TodoStatus>;
export const TodoEnergy = z.enum(["low", "med", "high"]);
export type TodoEnergy = z.infer<typeof TodoEnergy>;
export const TodoChecklistItem = z.object({
text: z.string().min(1),
done: z.boolean().default(false),
});
export type TodoChecklistItem = z.infer<typeof TodoChecklistItem>;
export const TodoPriority = z.number().int().min(0).max(3);
export type TodoPriority = z.infer<typeof TodoPriority>;
export const Todo = z.object({
id: z.string().uuid(),
title: z.string(),
description: z.string().nullable(),
status: TodoStatus,
priority: TodoPriority.nullable(),
due_at: z.string().nullable(),
tags: z.array(z.string()),
project_id: z.string().uuid().nullable(),
checklist: z.array(TodoChecklistItem),
energy: TodoEnergy.nullable(),
context: z.string().nullable(),
recurrence: z.string().nullable(),
ticket_url: z.string().nullable(),
verification_steps: z.array(z.string()),
ai_enriched: z.boolean(),
created_at: z.string(),
completed_at: z.string().nullable(),
});
export type Todo = z.infer<typeof Todo>;
export const TodoCreateDto = z.object({
title: z.string().min(1),
description: z.string().optional().nullable(),
status: TodoStatus.default("inbox"),
priority: TodoPriority.optional().nullable(),
due_at: z.string().datetime().optional().nullable(),
tags: z.array(z.string()).default([]),
project_id: z.string().uuid().optional().nullable(),
checklist: z.array(TodoChecklistItem).default([]),
energy: TodoEnergy.optional().nullable(),
context: z.string().optional().nullable(),
recurrence: z.string().optional().nullable(),
ticket_url: z.string().url().optional().nullable(),
verification_steps: z.array(z.string()).default([]),
});
export type TodoCreateDto = z.infer<typeof TodoCreateDto>;
export const TodoPatchDto = TodoCreateDto.partial().extend({
ai_enriched: z.boolean().optional(),
completed_at: z.string().datetime().optional().nullable(),
});
export type TodoPatchDto = z.infer<typeof TodoPatchDto>;
export const TodoListQuery = z.object({
status: TodoStatus.optional(),
priority: z.coerce.number().int().min(0).max(3).optional(),
context: z.string().optional(),
tags: z.array(z.string()).optional(),
project_id: z.string().uuid().optional(),
archived: z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
.optional(),
});
export type TodoListQuery = z.infer<typeof TodoListQuery>;
export const TodoAiEnrichDraft = z.object({
description: z.string().optional(),
priority: TodoPriority.optional(),
tags: z.array(z.string()).optional(),
context: z.string().optional(),
energy: TodoEnergy.optional(),
verification_steps: z.array(z.string()).optional(),
});
export type TodoAiEnrichDraft = z.infer<typeof TodoAiEnrichDraft>;
export const TodoAiEnrichResponse = z.object({
todo_id: z.string().uuid(),
draft: TodoAiEnrichDraft,
});
export type TodoAiEnrichResponse = z.infer<typeof TodoAiEnrichResponse>;
export const TodoAiEnrichApplyRequest = z.object({
draft: TodoAiEnrichDraft,
});
export type TodoAiEnrichApplyRequest = z.infer<typeof TodoAiEnrichApplyRequest>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase 5 — AI proposed actions (kept here so PWA + API agree) // Phase 5 — AI proposed actions (kept here so PWA + API agree)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------