diff --git a/drizzle/0003_loud_stardust.sql b/drizzle/0003_loud_stardust.sql new file mode 100644 index 0000000..d4dd3d3 --- /dev/null +++ b/drizzle/0003_loud_stardust.sql @@ -0,0 +1 @@ +ALTER TABLE "tasks" ADD COLUMN "due_date" timestamp; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..028b308 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,336 @@ +{ + "id": "c3b35242-78d1-42c3-b3c4-c3a50083f563", + "prevId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.badges": { + "name": "badges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_badges": { + "name": "task_badges", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "earned_at": { + "name": "earned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_badges_task_id_tasks_id_fk": { + "name": "task_badges_task_id_tasks_id_fk", + "tableFrom": "task_badges", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_badges_badge_id_badges_id_fk": { + "name": "task_badges_badge_id_badges_id_fk", + "tableFrom": "task_badges", + "tableTo": "badges", + "columnsFrom": [ + "badge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_badges_task_id_badge_id_pk": { + "name": "task_badges_task_id_badge_id_pk", + "columns": [ + "task_id", + "badge_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_category_id_categories_id_fk": { + "name": "tasks_category_id_categories_id_fk", + "tableFrom": "tasks", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f13b600..e254038 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1737504000000, "tag": "0002_add_category_pinning", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1769704889623, + "tag": "0003_loud_stardust", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index f121d3b..4990e73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -35,9 +35,17 @@ "placeholder": "Qu'est-ce qu'il faut faire ?", "descriptionPlaceholder": "Ajouter une description (optionnel)", "category": "Catégorie", + "dueDate": "Date limite", + "dueDatePlaceholder": "Sélectionner une date", "submit": "Ajouter", "submitting": "Ajout en cours..." }, + "dates": { + "today": "Aujourd'hui", + "tomorrow": "Demain", + "nextWeek": "Semaine prochaine", + "clear": "Effacer" + }, "categories": { "title": "Catégories", "noCategories": "Aucune catégorie. Ajoute ta première catégorie !", diff --git a/package.json b/package.json index 47afb37..25f76d2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.19", "drizzle-orm": "^0.45.1", "idb": "^8.0.3", "next": "16.1.3", @@ -31,6 +32,7 @@ "next-themes": "^0.4.6", "postgres": "^3.4.8", "react": "19.2.3", + "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "tailwind-merge": "^3.4.0", "zod": "^4.3.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab8f207..55d3f44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(postgres@3.4.8) @@ -41,6 +44,9 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-day-picker: + specifier: ^9.13.0 + version: 9.13.0(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) @@ -595,6 +601,9 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -2152,6 +2161,15 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3259,6 +3277,12 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -4455,6 +4479,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@date-fns/tz@1.4.1': {} + '@drizzle-team/brocli@0.10.2': {} '@ducanh2912/next-pwa@10.2.9(next@16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(webpack@5.104.1(esbuild@0.25.12))': @@ -5754,6 +5780,12 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + + dayjs@1.11.19: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6060,7 +6092,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -6082,7 +6114,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6948,6 +6980,13 @@ snapshots: dependencies: safe-buffer: 5.2.1 + react-day-picker@9.13.0(react@19.2.3): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.3 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 diff --git a/src/actions/stats.ts b/src/actions/stats.ts index 726bcec..b3f7acd 100644 --- a/src/actions/stats.ts +++ b/src/actions/stats.ts @@ -204,7 +204,7 @@ const calculateStreaks = (completedTasks: { updatedAt: Date }[]): { currentStrea // Check if streak is active (completed task today or yesterday) if (sortedDates[0] === today || sortedDates[0] === yesterday) { - let checkDate = new Date(sortedDates[0]); + const checkDate = new Date(sortedDates[0]); for (const dateStr of sortedDates) { const checkDateStr = getDateString(checkDate); diff --git a/src/actions/tasks.ts b/src/actions/tasks.ts index 808e64d..54c7484 100644 --- a/src/actions/tasks.ts +++ b/src/actions/tasks.ts @@ -2,7 +2,7 @@ import { db } from "@/db"; import { tasks, type NewTask } from "@/db/schema"; -import { eq, desc, and } from "drizzle-orm"; +import { eq, desc, and, asc, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; @@ -16,6 +16,7 @@ const createTaskSchema = z.object({ title: z.string().min(1, "Title is required").max(255), description: z.string().optional(), categoryId: z.string().uuid().optional().nullable(), + dueDate: z.coerce.date().optional().nullable(), }); const updateTaskSchema = z.object({ @@ -23,12 +24,26 @@ const updateTaskSchema = z.object({ title: z.string().min(1).max(255).optional(), description: z.string().optional().nullable(), categoryId: z.string().uuid().optional().nullable(), + dueDate: z.coerce.date().optional().nullable(), }); // === QUERIES === +// Custom ordering: overdue first, then by dueDate asc, then null last +const taskOrdering = [ + desc(tasks.isPinned), + // Overdue tasks first (dueDate < today and not completed) + desc( + sql`CASE WHEN ${tasks.dueDate} IS NOT NULL AND ${tasks.dueDate} < CURRENT_DATE AND ${tasks.isCompleted} = false THEN 1 ELSE 0 END` + ), + // Then by dueDate asc (nulls last) + asc(sql`CASE WHEN ${tasks.dueDate} IS NULL THEN 1 ELSE 0 END`), + asc(tasks.dueDate), + desc(tasks.createdAt), +]; + export const getTasks = async () => { return db.query.tasks.findMany({ - orderBy: [desc(tasks.isPinned), desc(tasks.createdAt)], + orderBy: taskOrdering, with: { category: true, }, @@ -38,7 +53,7 @@ export const getTasks = async () => { export const getPinnedTasks = async () => { return db.query.tasks.findMany({ where: and(eq(tasks.isPinned, true), eq(tasks.isCompleted, false)), - orderBy: [desc(tasks.createdAt)], + orderBy: taskOrdering, with: { category: true, }, @@ -48,7 +63,7 @@ export const getPinnedTasks = async () => { export const getIncompleteTasks = async () => { return db.query.tasks.findMany({ where: eq(tasks.isCompleted, false), - orderBy: [desc(tasks.isPinned), desc(tasks.createdAt)], + orderBy: taskOrdering, with: { category: true, }, @@ -58,7 +73,7 @@ export const getIncompleteTasks = async () => { export const getTasksByCategory = async (categoryId: string) => { return db.query.tasks.findMany({ where: eq(tasks.categoryId, categoryId), - orderBy: [desc(tasks.isPinned), desc(tasks.createdAt)], + orderBy: taskOrdering, with: { category: true, }, @@ -75,6 +90,7 @@ export const createTask = async (input: z.infer) => { title: validated.title, description: validated.description, categoryId: validated.categoryId, + dueDate: validated.dueDate, } satisfies NewTask) .returning(); @@ -84,12 +100,14 @@ export const createTask = async (input: z.infer) => { export const updateTask = async (input: z.infer) => { const validated = updateTaskSchema.parse(input); - const { id, ...data } = validated; + const { id, dueDate, ...data } = validated; const [task] = await db .update(tasks) .set({ ...data, + // Allow explicitly setting dueDate to null + ...(dueDate !== undefined && { dueDate }), updatedAt: new Date(), }) .where(eq(tasks.id, id)) diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index e331582..1bb2214 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -9,6 +9,7 @@ const createTaskSchema = z.object({ title: z.string().min(1, "Title is required"), category: z.string().optional().default("Urgent"), pinned: z.boolean().optional().default(false), + dueDate: z.coerce.date().optional().nullable(), }); const hashApiKey = (key: string) => { @@ -63,7 +64,7 @@ export const POST = async (request: NextRequest) => { ); } - const { title, category, pinned } = result.data; + const { title, category, pinned, dueDate } = result.data; // Find category by name (case insensitive) with WHERE clause const [matchedCategory] = await db @@ -114,6 +115,7 @@ export const POST = async (request: NextRequest) => { categoryId, isPinned: pinned, isCompleted: false, + dueDate: dueDate ?? null, }) .returning(); @@ -124,6 +126,7 @@ export const POST = async (request: NextRequest) => { title: newTask.title, category: categoryName, pinned: newTask.isPinned, + dueDate: newTask.dueDate?.toISOString() ?? null, }, }); } catch (error) { diff --git a/src/components/tasks/add-task-form.tsx b/src/components/tasks/add-task-form.tsx index 70a5d65..1a2e8b4 100644 --- a/src/components/tasks/add-task-form.tsx +++ b/src/components/tasks/add-task-form.tsx @@ -7,6 +7,7 @@ import { X } from "@phosphor-icons/react/dist/ssr"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { DatePicker } from "@/components/ui/date-picker"; import { createTask } from "@/actions/tasks"; import type { Category } from "@/db/schema"; import { cn } from "@/lib/utils"; @@ -25,12 +26,14 @@ export const AddTaskForm = ({ }: AddTaskFormProps) => { const router = useRouter(); const t = useTranslations("addTask"); + const tDates = useTranslations("dates"); const [isPending, startTransition] = useTransition(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [selectedCategoryId, setSelectedCategoryId] = useState( defaultCategoryId ); + const [dueDate, setDueDate] = useState(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -42,6 +45,7 @@ export const AddTaskForm = ({ title: title.trim(), description: description.trim() || undefined, categoryId: selectedCategoryId, + dueDate, }); // Build redirect URL with category if one was selected @@ -52,6 +56,7 @@ export const AddTaskForm = ({ setTitle(""); setDescription(""); setSelectedCategoryId(null); + setDueDate(null); onClose?.(); router.push(redirectUrl); }); @@ -124,6 +129,22 @@ export const AddTaskForm = ({ )} +
+

{t("dueDate")}

+ +
+ + )} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Quick picks */} +
+ + + +
+ + {/* Calendar */} + +
+ )} + + ); +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 6ca7596..ae6ed7c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -32,6 +32,7 @@ export const tasks = pgTable("tasks", { isCompleted: boolean("is_completed").notNull().default(false), isPinned: boolean("is_pinned").notNull().default(false), categoryId: uuid("category_id").references(() => categories.id), + dueDate: timestamp("due_date"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..56d2062 --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,88 @@ +import dayjs from "dayjs"; +import "dayjs/locale/fr"; +import isToday from "dayjs/plugin/isToday"; +import isTomorrow from "dayjs/plugin/isTomorrow"; +import relativeTime from "dayjs/plugin/relativeTime"; + +// Configure dayjs with plugins and French locale +dayjs.extend(isToday); +dayjs.extend(isTomorrow); +dayjs.extend(relativeTime); +dayjs.locale("fr"); + +export type DueDateStatus = "overdue" | "today" | "tomorrow" | "upcoming" | null; + +type DateTemplate = "short" | "full" | "dayMonth"; + +const DATE_TEMPLATES: Record = { + short: "DD/MM", + full: "DD/MM/YYYY", + dayMonth: "ddd D MMM", +}; + +export const dayjsDate = (date: Date | string | null | undefined) => { + if (!date) return null; + return dayjs(date).locale("fr"); +}; + +export const formatDate = ( + date: Date | string | null | undefined, + template: DateTemplate = "dayMonth" +): string => { + const d = dayjsDate(date); + if (!d) return ""; + return d.format(DATE_TEMPLATES[template]); +}; + +export const getDueDateStatus = ( + date: Date | string | null | undefined +): DueDateStatus => { + const d = dayjsDate(date); + if (!d) return null; + + const now = dayjs().startOf("day"); + const dueDate = d.startOf("day"); + + if (dueDate.isBefore(now)) return "overdue"; + if (d.isToday()) return "today"; + if (d.isTomorrow()) return "tomorrow"; + return "upcoming"; +}; + +export const getDueDateLabel = ( + date: Date | string | null | undefined +): string => { + const d = dayjsDate(date); + if (!d) return ""; + + const status = getDueDateStatus(date); + + switch (status) { + case "overdue": + return "En retard"; + case "today": + return "Aujourd'hui"; + case "tomorrow": + return "Demain"; + case "upcoming": + return d.format("ddd D MMM"); + default: + return ""; + } +}; + +// Helper to get date for quick picks +export const getQuickPickDate = ( + pick: "today" | "tomorrow" | "nextWeek" +): Date => { + const now = dayjs(); + + switch (pick) { + case "today": + return now.toDate(); + case "tomorrow": + return now.add(1, "day").toDate(); + case "nextWeek": + return now.add(1, "week").startOf("week").add(1, "day").toDate(); // Monday next week + } +};