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",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
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({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import "reflect-metadata";
|
||||
import "tsconfig-paths/register";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { APP_CONFIG } from "../../config/config.module";
|
||||
import type { AppConfig } from "../../config/env";
|
||||
import { APP_CONFIG } from "@/config/config.module";
|
||||
import type { AppConfig } from "@/config/env";
|
||||
|
||||
@Injectable()
|
||||
export class BearerMiddleware implements NestMiddleware {
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
JobPatchDto,
|
||||
JobSearchCriteriaUpsert,
|
||||
} from "@ordinarthur-os/shared";
|
||||
import { ZodPipe } from "../../lib/zod-pipe";
|
||||
import { ZodPipe } from "@/lib/zod-pipe";
|
||||
import { JobsService } from "./jobs.service";
|
||||
|
||||
@Controller("jobs")
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
JobSearchCriteriaUpsert,
|
||||
} from "@ordinarthur-os/shared";
|
||||
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 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,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"verbatimModuleSyntax": 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,
|
||||
"tag": "0001_jobs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1744934400000,
|
||||
"tag": "0002_todos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,16 +2,19 @@
|
||||
"name": "@ordinarthur-os/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "tsx src/migrate.ts",
|
||||
"migrate": "tsx --env-file=../../apps/api/.env src/migrate.ts",
|
||||
"studio": "drizzle-kit studio",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export * as schema from "./schema";
|
||||
export { appSchema } from "./schema";
|
||||
export * as schema from "./schema/index";
|
||||
export { appSchema } from "./schema/index";
|
||||
export { createDb, type Db, type DbHandle } from "./client";
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*
|
||||
* DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import { createDb } from "./client";
|
||||
|
||||
@ -16,7 +17,7 @@ async function main() {
|
||||
const { db, close } = createDb(url);
|
||||
try {
|
||||
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");
|
||||
} finally {
|
||||
await close();
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { appSchema } from "./_schema";
|
||||
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",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"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:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"persistent": true,
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {},
|
||||
"typecheck": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user