pg todo 2
This commit is contained in:
parent
f851da4677
commit
32f3105bef
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/db build)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/db tsc --noEmit)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)",
|
||||||
|
"Read(//private/tmp/test_ts/pkg/**)",
|
||||||
|
"Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/db migrate)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@
|
|||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
// Charge apps/api/.env (si présent) avant de valider process.env.
|
||||||
|
// Utilise l'API native Node 20.12+ — pas de dépendance `dotenv`.
|
||||||
|
const envPath = path.resolve(__dirname, "..", "..", ".env");
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
process.loadEnvFile(envPath);
|
||||||
|
}
|
||||||
|
|
||||||
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"),
|
||||||
PORT: z.coerce.number().int().positive().default(3000),
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
import "tsconfig-paths/register";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
|
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { APP_CONFIG } from "../../config/config.module";
|
import { APP_CONFIG } from "@/config/config.module";
|
||||||
import type { AppConfig } from "../../config/env";
|
import type { AppConfig } from "@/config/env";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BearerMiddleware implements NestMiddleware {
|
export class BearerMiddleware implements NestMiddleware {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
JobPatchDto,
|
JobPatchDto,
|
||||||
JobSearchCriteriaUpsert,
|
JobSearchCriteriaUpsert,
|
||||||
} from "@ordinarthur-os/shared";
|
} from "@ordinarthur-os/shared";
|
||||||
import { ZodPipe } from "../../lib/zod-pipe";
|
import { ZodPipe } from "@/lib/zod-pipe";
|
||||||
import { JobsService } from "./jobs.service";
|
import { JobsService } from "./jobs.service";
|
||||||
|
|
||||||
@Controller("jobs")
|
@Controller("jobs")
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
JobSearchCriteriaUpsert,
|
JobSearchCriteriaUpsert,
|
||||||
} from "@ordinarthur-os/shared";
|
} from "@ordinarthur-os/shared";
|
||||||
import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
|
import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
|
||||||
import { InjectDb, type Db } from "../../db/db.module";
|
import { InjectDb, type Db } from "@/db/db.module";
|
||||||
|
|
||||||
const { jobs, jobSearchCriteria } = schema;
|
const { jobs, jobSearchCriteria } = schema;
|
||||||
const RETENTION_DAYS = 30;
|
const RETENTION_DAYS = 30;
|
||||||
|
|||||||
60
apps/api/src/modules/todos/todos.controller.ts
Normal file
60
apps/api/src/modules/todos/todos.controller.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
TodoAiEnrichApplyRequest,
|
||||||
|
TodoCreateDto,
|
||||||
|
TodoListQuery,
|
||||||
|
TodoPatchDto,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { ZodPipe } from "@/lib/zod-pipe";
|
||||||
|
import { TodosService } from "./todos.service";
|
||||||
|
|
||||||
|
@Controller("todos")
|
||||||
|
export class TodosController {
|
||||||
|
constructor(private readonly todos: TodosService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query(new ZodPipe(TodoListQuery)) query: TodoListQuery) {
|
||||||
|
return this.todos.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body(new ZodPipe(TodoCreateDto)) input: TodoCreateDto) {
|
||||||
|
return this.todos.create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
patch(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body(new ZodPipe(TodoPatchDto)) patch: TodoPatchDto,
|
||||||
|
) {
|
||||||
|
return this.todos.patch(id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
remove(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.todos.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/ai-enrich")
|
||||||
|
aiEnrich(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.todos.aiEnrich(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/ai-enrich/apply")
|
||||||
|
applyAiEnrich(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body(new ZodPipe(TodoAiEnrichApplyRequest)) body: TodoAiEnrichApplyRequest,
|
||||||
|
) {
|
||||||
|
return this.todos.applyAiEnrich(id, body.draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/modules/todos/todos.module.ts
Normal file
9
apps/api/src/modules/todos/todos.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TodosController } from "./todos.controller";
|
||||||
|
import { TodosService } from "./todos.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TodosController],
|
||||||
|
providers: [TodosService],
|
||||||
|
})
|
||||||
|
export class TodosModule {}
|
||||||
209
apps/api/src/modules/todos/todos.service.ts
Normal file
209
apps/api/src/modules/todos/todos.service.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { schema } from "@ordinarthur-os/db";
|
||||||
|
import {
|
||||||
|
Todo,
|
||||||
|
TodoAiEnrichDraft,
|
||||||
|
TodoAiEnrichResponse,
|
||||||
|
TodoCreateDto,
|
||||||
|
TodoListQuery,
|
||||||
|
TodoPatchDto,
|
||||||
|
TodoPriority,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { and, arrayOverlaps, desc, eq, ilike, ne, sql } from "drizzle-orm";
|
||||||
|
import { InjectDb, type Db } from "@/db/db.module";
|
||||||
|
|
||||||
|
const { todos } = schema;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TodosService {
|
||||||
|
constructor(@InjectDb() private readonly db: Db) {}
|
||||||
|
|
||||||
|
async list(query: TodoListQuery): Promise<Todo[]> {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (query.archived === true) {
|
||||||
|
conditions.push(eq(todos.status, "archived"));
|
||||||
|
} else if (!query.status) {
|
||||||
|
conditions.push(ne(todos.status, "archived"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status) conditions.push(eq(todos.status, query.status));
|
||||||
|
if (typeof query.priority === "number")
|
||||||
|
conditions.push(eq(todos.priority, query.priority));
|
||||||
|
if (query.context) conditions.push(ilike(todos.context, `%${query.context}%`));
|
||||||
|
if (query.project_id) conditions.push(eq(todos.projectId, query.project_id));
|
||||||
|
if (query.tags?.length) conditions.push(arrayOverlaps(todos.tags, query.tags));
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(todos)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(todos.createdAt))
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
return rows.map(rowToTodo);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: TodoCreateDto): Promise<Todo> {
|
||||||
|
const rows = await this.db
|
||||||
|
.insert(todos)
|
||||||
|
.values(dtoToInsert(input))
|
||||||
|
.returning();
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error("Insert returned no row");
|
||||||
|
return rowToTodo(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(id: string, patch: TodoPatchDto): Promise<Todo> {
|
||||||
|
const existing = await this.getById(id);
|
||||||
|
const values = dtoToInsert({ ...existing, ...patch } as TodoCreateDto);
|
||||||
|
|
||||||
|
// completed_at : si le patch le précise explicitement, on l'honore ;
|
||||||
|
// sinon on le dérive du status.
|
||||||
|
let completedAt: Date | null | undefined = undefined;
|
||||||
|
if ("completed_at" in patch && patch.completed_at !== undefined) {
|
||||||
|
completedAt = patch.completed_at ? new Date(patch.completed_at) : null;
|
||||||
|
} else if (patch.status === "done") {
|
||||||
|
completedAt = new Date();
|
||||||
|
} else if (patch.status) {
|
||||||
|
completedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(todos)
|
||||||
|
.set({
|
||||||
|
...values,
|
||||||
|
...(completedAt !== undefined ? { completedAt } : {}),
|
||||||
|
...(patch.ai_enriched !== undefined ? { aiEnriched: patch.ai_enriched } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(todos.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) throw new NotFoundException(`Todo ${id} not found`);
|
||||||
|
return rowToTodo(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.db.delete(todos).where(eq(todos.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async aiEnrich(id: string): Promise<TodoAiEnrichResponse> {
|
||||||
|
const todo = await this.getById(id);
|
||||||
|
const draft = buildEnrichmentDraft(todo);
|
||||||
|
return { todo_id: todo.id, draft };
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyAiEnrich(id: string, draft: TodoAiEnrichDraft): Promise<Todo> {
|
||||||
|
const todo = await this.getById(id);
|
||||||
|
return this.patch(id, {
|
||||||
|
description: draft.description ?? todo.description,
|
||||||
|
priority: draft.priority ?? todo.priority,
|
||||||
|
tags: draft.tags ?? todo.tags,
|
||||||
|
context: draft.context ?? todo.context,
|
||||||
|
energy: draft.energy ?? todo.energy,
|
||||||
|
verification_steps: draft.verification_steps ?? todo.verification_steps,
|
||||||
|
ai_enriched: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getById(id: string): Promise<Todo> {
|
||||||
|
const [row] = await this.db.select().from(todos).where(eq(todos.id, id));
|
||||||
|
if (!row) throw new NotFoundException(`Todo ${id} not found`);
|
||||||
|
return rowToTodo(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rowToTodo(row: typeof todos.$inferSelect): Todo {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? null,
|
||||||
|
status: row.status as Todo["status"],
|
||||||
|
priority: row.priority as TodoPriority | null,
|
||||||
|
due_at: row.dueAt?.toISOString() ?? null,
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
project_id: row.projectId ?? null,
|
||||||
|
checklist: (row.checklist as Todo["checklist"]) ?? [],
|
||||||
|
energy: row.energy as Todo["energy"] ?? null,
|
||||||
|
context: row.context ?? null,
|
||||||
|
recurrence: row.recurrence ?? null,
|
||||||
|
ticket_url: row.ticketUrl ?? null,
|
||||||
|
verification_steps: row.verificationSteps ?? [],
|
||||||
|
ai_enriched: row.aiEnriched,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
completed_at: row.completedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtoToInsert(input: TodoCreateDto) {
|
||||||
|
return {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
status: input.status ?? "inbox",
|
||||||
|
priority: input.priority ?? null,
|
||||||
|
dueAt: input.due_at ? new Date(input.due_at) : null,
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
projectId: input.project_id ?? null,
|
||||||
|
checklist: input.checklist ?? [],
|
||||||
|
energy: input.energy ?? null,
|
||||||
|
context: input.context ?? null,
|
||||||
|
recurrence: input.recurrence ?? null,
|
||||||
|
ticketUrl: input.ticket_url ?? null,
|
||||||
|
verificationSteps: input.verification_steps ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enrichissement IA heuristique (remplacé par Mistral en Phase 5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildEnrichmentDraft(todo: Todo): TodoAiEnrichDraft {
|
||||||
|
const title = todo.title.toLowerCase();
|
||||||
|
const tags = inferTags(todo.title, todo.tags);
|
||||||
|
|
||||||
|
if (title.includes("appeler") || title.includes("call")) {
|
||||||
|
return {
|
||||||
|
description: todo.description ?? "Préparer les points, lancer l'appel, noter le prochain pas.",
|
||||||
|
priority: todo.priority ?? 2,
|
||||||
|
tags,
|
||||||
|
context: todo.context ?? "@phone",
|
||||||
|
energy: todo.energy ?? "med",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.includes("mail") || title.includes("email") || title.includes("répondre")) {
|
||||||
|
return {
|
||||||
|
description: todo.description ?? "Rédiger, vérifier les PJ et envoyer.",
|
||||||
|
priority: todo.priority ?? 1,
|
||||||
|
tags,
|
||||||
|
context: todo.context ?? "@laptop",
|
||||||
|
energy: todo.energy ?? "low",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: todo.description ?? "Découper en une première action concrète et avancer.",
|
||||||
|
priority: todo.priority ?? 1,
|
||||||
|
tags,
|
||||||
|
context: todo.context ?? "@focus",
|
||||||
|
energy: todo.energy ?? "med",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTags(title: string, currentTags: string[]) {
|
||||||
|
const base = new Set(currentTags.map((t) => t.trim()).filter(Boolean));
|
||||||
|
const tokens = title
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9àâçéèêëîïôûùüÿñæœ]+/i)
|
||||||
|
.filter((t) => t.length >= 4)
|
||||||
|
.filter((t) => !STOPWORDS.has(t))
|
||||||
|
.slice(0, 4);
|
||||||
|
for (const token of tokens) base.add(token);
|
||||||
|
return Array.from(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set(["avec", "dans", "pour", "faire", "plus", "arthur", "todo", "that", "this", "from"]);
|
||||||
@ -8,6 +8,10 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
"verbatimModuleSyntax": false,
|
"verbatimModuleSyntax": false,
|
||||||
"isolatedModules": false
|
"isolatedModules": false
|
||||||
},
|
},
|
||||||
|
|||||||
221
apps/pwa/src/routes/todos.tsx
Normal file
221
apps/pwa/src/routes/todos.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Todo, TodoCreateDto, TodoPatchDto, TodoStatus } from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { BigHeading, Label, SectionHeader } from "@/design";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/todos")({ component: TodosPage });
|
||||||
|
|
||||||
|
function TodosPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [showDone, setShowDone] = useState(false);
|
||||||
|
|
||||||
|
const todos = useQuery({
|
||||||
|
queryKey: ["todos", showDone],
|
||||||
|
queryFn: () =>
|
||||||
|
api<Todo[]>(showDone ? "/todos?status=done" : "/todos"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationFn: (payload: TodoCreateDto) =>
|
||||||
|
api<Todo>("/todos", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setInput("");
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchTodo = useMutation({
|
||||||
|
mutationFn: ({ id, patch }: { id: string; patch: TodoPatchDto }) =>
|
||||||
|
api<Todo>(`/todos/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (id: string) => api<void>(`/todos/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = input.trim();
|
||||||
|
if (!title) return;
|
||||||
|
createTodo.mutate({ title, status: "inbox", tags: [], checklist: [], verification_steps: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e as unknown as React.FormEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = todos.data ?? [];
|
||||||
|
const active = data.filter((t) => t.status !== "done" && t.status !== "archived");
|
||||||
|
const done = data.filter((t) => t.status === "done");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
|
||||||
|
<BigHeading>
|
||||||
|
Capture <em>rapide</em>, traitement via le magic button.
|
||||||
|
</BigHeading>
|
||||||
|
<p className="max-w-2xl font-sans text-sm leading-6 text-ink">
|
||||||
|
Écris une tâche, appuie sur Entrée. Le reste (priorité, contexte, tags) sera rempli par
|
||||||
|
le bouton vocal en Phase 5.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SectionHeader number="01" label="CAPTURE" title="Ajoute une tâche" />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="border border-ink">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Relancer Paul… (Entrée pour valider, Shift+Entrée pour sauter une ligne)"
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted resize-none"
|
||||||
|
/>
|
||||||
|
<div className="border-t border-ink px-4 py-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || createTodo.isPending}
|
||||||
|
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SectionHeader
|
||||||
|
number="02"
|
||||||
|
label="LISTE"
|
||||||
|
title={todos.isLoading ? "Chargement…" : `${active.length} tâche${active.length !== 1 ? "s" : ""} en cours`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border border-ink divide-y divide-ink">
|
||||||
|
{todos.isLoading ? (
|
||||||
|
<EmptyState text="Chargement…" />
|
||||||
|
) : todos.isError ? (
|
||||||
|
<EmptyState text="Impossible de charger les todos. Vérifie l'API." />
|
||||||
|
) : active.length === 0 ? (
|
||||||
|
<EmptyState text="Aucune tâche en cours. Capture la prochaine ci-dessus." />
|
||||||
|
) : (
|
||||||
|
active.map((todo) => (
|
||||||
|
<TodoRow
|
||||||
|
key={todo.id}
|
||||||
|
todo={todo}
|
||||||
|
onToggleDone={() =>
|
||||||
|
patchTodo.mutate({
|
||||||
|
id: todo.id,
|
||||||
|
patch: { status: "done" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDelete={() => deleteTodo.mutate(todo.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section "faites" collapsible */}
|
||||||
|
{(done.length > 0 || showDone) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDone((v) => !v)}
|
||||||
|
className="font-mono text-[11px] uppercase tracking-label text-ink border border-ink px-4 py-2 hover:bg-ink hover:text-bg transition-colors"
|
||||||
|
>
|
||||||
|
{showDone ? "Masquer les faites" : `Voir les faites (${done.length})`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDone && (
|
||||||
|
<div className="border border-ink divide-y divide-ink opacity-60">
|
||||||
|
{done.map((todo) => (
|
||||||
|
<TodoRow
|
||||||
|
key={todo.id}
|
||||||
|
todo={todo}
|
||||||
|
onToggleDone={() =>
|
||||||
|
patchTodo.mutate({ id: todo.id, patch: { status: "inbox" } })
|
||||||
|
}
|
||||||
|
onDelete={() => deleteTodo.mutate(todo.id)}
|
||||||
|
isDone
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoRow({
|
||||||
|
todo,
|
||||||
|
onToggleDone,
|
||||||
|
onDelete,
|
||||||
|
isDone = false,
|
||||||
|
}: {
|
||||||
|
todo: Todo;
|
||||||
|
onToggleDone: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isDone?: boolean;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(todo.title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex items-start gap-3 px-4 py-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleDone}
|
||||||
|
aria-label={isDone ? "Rouvrir" : "Marquer comme fait"}
|
||||||
|
className={[
|
||||||
|
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink transition-colors",
|
||||||
|
isDone ? "bg-ink" : "hover:bg-ink/20",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Titre éditable */}
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const next = e.target.value.trim();
|
||||||
|
if (next && next !== todo.title) {
|
||||||
|
// patch silencieux via fetch direct pour ne pas invalider inutilement
|
||||||
|
void api<Todo>(`/todos/${todo.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ title: next } satisfies TodoPatchDto),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"flex-1 min-w-0 bg-transparent font-sans text-sm text-ink outline-none border-b border-transparent focus:border-ink",
|
||||||
|
isDone ? "line-through text-muted" : "",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Supprimer */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label="Supprimer"
|
||||||
|
className="flex-shrink-0 font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink transition-colors px-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8">
|
||||||
|
<p className="font-sans text-sm text-muted">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
packages/db/migrations/0002_todos.sql
Normal file
27
packages/db/migrations/0002_todos.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- 0002_todos.sql — Phase 2
|
||||||
|
set search_path to ordinarthur_os, public;
|
||||||
|
|
||||||
|
create table if not exists ordinarthur_os.todos (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
status text not null default 'inbox'
|
||||||
|
check (status in ('inbox', 'todo', 'doing', 'done', 'archived')),
|
||||||
|
priority smallint check (priority between 0 and 3),
|
||||||
|
due_at timestamptz,
|
||||||
|
tags text[] not null default '{}',
|
||||||
|
project_id uuid,
|
||||||
|
checklist jsonb not null default '[]'::jsonb,
|
||||||
|
energy text check (energy in ('low', 'med', 'high')),
|
||||||
|
context text,
|
||||||
|
recurrence text,
|
||||||
|
ticket_url text,
|
||||||
|
verification_steps text[] not null default '{}',
|
||||||
|
ai_enriched boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists todos_status_idx on ordinarthur_os.todos(status);
|
||||||
|
create index if not exists todos_due_at_idx on ordinarthur_os.todos(due_at);
|
||||||
|
create index if not exists todos_tags_gin on ordinarthur_os.todos using gin (tags);
|
||||||
@ -15,6 +15,13 @@
|
|||||||
"when": 1744848060000,
|
"when": 1744848060000,
|
||||||
"tag": "0001_jobs",
|
"tag": "0001_jobs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1744934400000,
|
||||||
|
"tag": "0002_todos",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,19 @@
|
|||||||
"name": "@ordinarthur-os/db",
|
"name": "@ordinarthur-os/db",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"main": "./dist/index.js",
|
||||||
"main": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": {
|
||||||
"./schema": "./src/schema/index.ts"
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"migrate": "tsx src/migrate.ts",
|
"migrate": "tsx --env-file=../../apps/api/.env src/migrate.ts",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export * as schema from "./schema";
|
export * as schema from "./schema/index";
|
||||||
export { appSchema } from "./schema";
|
export { appSchema } from "./schema/index";
|
||||||
export { createDb, type Db, type DbHandle } from "./client";
|
export { createDb, type Db, type DbHandle } from "./client";
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate
|
* DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate
|
||||||
*/
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
import { createDb } from "./client";
|
import { createDb } from "./client";
|
||||||
|
|
||||||
@ -16,7 +17,7 @@ async function main() {
|
|||||||
const { db, close } = createDb(url);
|
const { db, close } = createDb(url);
|
||||||
try {
|
try {
|
||||||
console.log("[migrate] application des migrations…");
|
console.log("[migrate] application des migrations…");
|
||||||
await migrate(db, { migrationsFolder: new URL("../migrations", import.meta.url).pathname });
|
await migrate(db, { migrationsFolder: path.resolve(__dirname, "..", "migrations") });
|
||||||
console.log("[migrate] ✓ à jour");
|
console.log("[migrate] ✓ à jour");
|
||||||
} finally {
|
} finally {
|
||||||
await close();
|
await close();
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export { appSchema } from "./_schema";
|
export { appSchema } from "./_schema";
|
||||||
export * from "./jobs";
|
export * from "./jobs";
|
||||||
|
export * from "./todos";
|
||||||
|
|||||||
34
packages/db/src/schema/todos.ts
Normal file
34
packages/db/src/schema/todos.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { boolean, index, jsonb, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { appSchema } from "./_schema";
|
||||||
|
|
||||||
|
export const todos = appSchema.table(
|
||||||
|
"todos",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: text("status").notNull().default("inbox"),
|
||||||
|
priority: smallint("priority"),
|
||||||
|
dueAt: timestamp("due_at", { withTimezone: true }),
|
||||||
|
tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
|
||||||
|
projectId: uuid("project_id"),
|
||||||
|
checklist: jsonb("checklist").notNull().default(sql`'[]'::jsonb`),
|
||||||
|
energy: text("energy"),
|
||||||
|
context: text("context"),
|
||||||
|
recurrence: text("recurrence"),
|
||||||
|
ticketUrl: text("ticket_url"),
|
||||||
|
verificationSteps: text("verification_steps").array().notNull().default(sql`'{}'::text[]`),
|
||||||
|
aiEnriched: boolean("ai_enriched").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
statusIdx: index("todos_status_idx").on(t.status),
|
||||||
|
dueAtIdx: index("todos_due_at_idx").on(t.dueAt),
|
||||||
|
tagsGin: index("todos_tags_gin").using("gin", t.tags),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TodoRow = typeof todos.$inferSelect;
|
||||||
|
export type TodoInsert = typeof todos.$inferInsert;
|
||||||
@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "ESNext",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Node",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"noEmit": true
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["src", "drizzle.config.ts"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
tsconfig-paths:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true,
|
||||||
|
"dependsOn": ["^build"]
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user