From c2b20a9f10c840348bded5933dd6257dcab742ad Mon Sep 17 00:00:00 2001 From: Stefan ATZLESBERGER Date: Thu, 16 Apr 2026 16:05:22 +0200 Subject: [PATCH] snapshot --- src/components/ui/date-input.tsx | 25 ++++++++ src/features/notes/sortOptions.ts | 2 + src/features/snippets/SnippetCard.tsx | 52 +++++++++++++++-- .../components/SnippetDeadlineBadge.tsx | 44 ++++++++++++++ .../snippets/components/SnippetEditDialog.tsx | 24 ++++++++ src/index.css | 58 +++++++++++++++++++ src/lib/snippetDeadline.ts | 55 ++++++++++++++++++ src/server/db.ts | 6 ++ src/server/routes/snippetsHandlers.ts | 28 ++++++++- src/server/snippets.ts | 44 +++++++++++--- src/types/snippet.ts | 2 + 11 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 src/components/ui/date-input.tsx create mode 100644 src/features/snippets/components/SnippetDeadlineBadge.tsx create mode 100644 src/lib/snippetDeadline.ts diff --git a/src/components/ui/date-input.tsx b/src/components/ui/date-input.tsx new file mode 100644 index 0000000..9ba397d --- /dev/null +++ b/src/components/ui/date-input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** Styled native `` (Kalender-Popup bleibt browserabhängig). */ +const DateInput = React.forwardRef, "type">>( + ({ className, ...props }, ref) => ( + + ), +); +DateInput.displayName = "DateInput"; + +export { DateInput }; diff --git a/src/features/notes/sortOptions.ts b/src/features/notes/sortOptions.ts index 29ffacf..49fe2f4 100644 --- a/src/features/notes/sortOptions.ts +++ b/src/features/notes/sortOptions.ts @@ -1,6 +1,8 @@ export const SORT_OPTIONS = [ { value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" }, { value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" }, + { value: "deadline_asc", label: "Deadline (früheste zuerst)" }, + { value: "deadline_desc", label: "Deadline (späteste zuerst)" }, { value: "title_asc", label: "Titel A–Z" }, { value: "title_desc", label: "Titel Z–A" }, ] as const; diff --git a/src/features/snippets/SnippetCard.tsx b/src/features/snippets/SnippetCard.tsx index b475102..5a9582a 100644 --- a/src/features/snippets/SnippetCard.tsx +++ b/src/features/snippets/SnippetCard.tsx @@ -1,8 +1,10 @@ import { Pencil, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { Snippet } from "@/types/snippet"; +import { deadlineToDateInput } from "@/lib/snippetDeadline"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { SnippetDeadlineBadge } from "@/features/snippets/components/SnippetDeadlineBadge"; import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDialog"; import { TagPill } from "@/features/snippets/components/TagPill"; import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils"; @@ -18,12 +20,14 @@ type Props = { }; export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) { - const pristineEmpty = !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0; + const pristineEmpty = + !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0 && !snippet.deadline_at; const [dialogOpen, setDialogOpen] = useState(pristineEmpty); const [dirty, setDirty] = useState(false); const [body, setBody] = useState(() => snippet.body ?? ""); const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); + const [deadlineDate, setDeadlineDate] = useState(() => deadlineToDateInput(snippet.deadline_at)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -33,11 +37,13 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep if (dialogOpen) return; setBody(snippet.body ?? ""); setTagsStr((snippet.tags ?? []).join(", ")); - }, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags]); + setDeadlineDate(deadlineToDateInput(snippet.deadline_at)); + }, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags, snippet.deadline_at]); const hydrateFromSnippet = () => { setBody(snippet.body ?? ""); setTagsStr((snippet.tags ?? []).join(", ")); + setDeadlineDate(deadlineToDateInput(snippet.deadline_at)); setDirty(false); setError(null); }; @@ -68,6 +74,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep title: null, body, tags: tagsFromString(tagsStr), + deadline_at: deadlineDate.trim() === "" ? null : deadlineDate.trim(), }), }); if (!res.ok) { @@ -85,6 +92,29 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep } }; + async function clearDeadlineRemote() { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/snippets/${snippet.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deadline_at: null }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? res.statusText); + } + const updated = (await res.json()) as Snippet; + onReplace(updated); + setDeadlineDate(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Deadline konnte nicht entfernt werden"); + } finally { + setBusy(false); + } + } + async function deleteRemote(askConfirm: boolean) { if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return; setBusy(true); @@ -108,7 +138,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep return ( <> - +
+ {snippet.deadline_at ? ( +
+ void clearDeadlineRemote()} + /> +
+ ) : null}
- +
{snippet.tags.length ? ( snippet.tags.map((t, i) => ( @@ -192,6 +231,11 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep markDirty(); setTagsStr(v); }} + deadlineDate={deadlineDate} + onDeadlineChange={v => { + markDirty(); + setDeadlineDate(v); + }} busy={busy} error={error} onSave={() => void save()} diff --git a/src/features/snippets/components/SnippetDeadlineBadge.tsx b/src/features/snippets/components/SnippetDeadlineBadge.tsx new file mode 100644 index 0000000..4ef761f --- /dev/null +++ b/src/features/snippets/components/SnippetDeadlineBadge.tsx @@ -0,0 +1,44 @@ +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { deadlineBadgeClass, deadlineTone, formatDeadlineLabel } from "@/lib/snippetDeadline"; +import { Button } from "@/components/ui/button"; + +type Props = { + deadlineAt: string; + busy?: boolean; + onMarkDone?: () => void; +}; + +export function SnippetDeadlineBadge({ deadlineAt, busy, onMarkDone }: Props) { + const tone = deadlineTone(deadlineAt); + return ( +
+ + {formatDeadlineLabel(deadlineAt)} + + {onMarkDone ? ( + + ) : null} +
+ ); +} diff --git a/src/features/snippets/components/SnippetEditDialog.tsx b/src/features/snippets/components/SnippetEditDialog.tsx index 293bc82..885fd36 100644 --- a/src/features/snippets/components/SnippetEditDialog.tsx +++ b/src/features/snippets/components/SnippetEditDialog.tsx @@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Snippet } from "@/types/snippet"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { DateInput } from "@/components/ui/date-input"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions"; import { TagPill } from "@/features/snippets/components/TagPill"; @@ -20,6 +22,8 @@ type Props = { tagsStr: string; onBodyChange: (v: string) => void; onTagsStrChange: (v: string) => void; + deadlineDate: string; + onDeadlineChange: (v: string) => void; busy: boolean; error: string | null; onSave: () => void; @@ -36,6 +40,8 @@ export function SnippetEditDialog({ tagsStr, onBodyChange, onTagsStrChange, + deadlineDate, + onDeadlineChange, busy, error, onSave, @@ -133,6 +139,24 @@ export function SnippetEditDialog({
) : null}
+
+
+ + onDeadlineChange(e.target.value)} /> +
+ +
{error ?

{error}

: null}
diff --git a/src/index.css b/src/index.css index 168b306..61d2ade 100644 --- a/src/index.css +++ b/src/index.css @@ -292,4 +292,62 @@ body::before { .tag-tree-loading { @apply flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground; } + + /* + * Native — Feld, WebKit-Segmente & Kalender-Icon. + * Das aufklappende Kalender-Popup gestaltet weiterhin der Browser. + */ + .date-field { + color-scheme: light; + } + + .dark .date-field { + color-scheme: dark; + } + + .date-field::-webkit-datetime-edit, + .date-field::-webkit-datetime-edit-fields-wrapper { + padding: 0.05rem 0; + } + + .date-field::-webkit-datetime-edit-text { + color: var(--muted-foreground); + padding: 0 0.1rem; + } + + .date-field::-webkit-datetime-edit-day-field, + .date-field::-webkit-datetime-edit-month-field, + .date-field::-webkit-datetime-edit-year-field { + padding: 0.1rem 0.2rem; + border-radius: 0.2rem; + } + + .date-field:focus::-webkit-datetime-edit-day-field, + .date-field:focus::-webkit-datetime-edit-month-field, + .date-field:focus::-webkit-datetime-edit-year-field, + .date-field:focus-visible::-webkit-datetime-edit-day-field, + .date-field:focus-visible::-webkit-datetime-edit-month-field, + .date-field:focus-visible::-webkit-datetime-edit-year-field { + background-color: color-mix(in oklch, var(--accent) 45%, transparent); + color: var(--accent-foreground); + } + + .date-field::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.65; + border-radius: 0.25rem; + padding: 0.15rem 0.35rem; + margin-inline-start: 0.125rem; + transition: opacity 0.12s ease, background-color 0.12s ease; + } + + .date-field:hover::-webkit-calendar-picker-indicator { + opacity: 1; + background-color: color-mix(in oklch, var(--muted) 75%, transparent); + } + + .date-field:focus::-webkit-calendar-picker-indicator, + .date-field:focus-visible::-webkit-calendar-picker-indicator { + opacity: 1; + } } diff --git a/src/lib/snippetDeadline.ts b/src/lib/snippetDeadline.ts new file mode 100644 index 0000000..0fa6d35 --- /dev/null +++ b/src/lib/snippetDeadline.ts @@ -0,0 +1,55 @@ +/** Kalendertag lokal als `YYYY-MM-DD` (für Vergleich mit DB). */ +export function localDateKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** UI-Wert für `` aus API-Wert. */ +export function deadlineToDateInput(deadlineAt: string | null | undefined): string { + if (!deadlineAt) return ""; + const s = deadlineAt.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + const t = new Date(s); + if (Number.isNaN(t.getTime())) return ""; + return localDateKey(t); +} + +export type DeadlineTone = "none" | "upcoming" | "today" | "overdue"; + +/** Blau = künftig (nicht heute), Orange = heute fällig, Rot = überschritten. */ +export function deadlineTone(deadlineAt: string | null | undefined): DeadlineTone { + if (!deadlineAt) return "none"; + const key = deadlineToDateInput(deadlineAt); + if (!key) return "none"; + const today = localDateKey(new Date()); + if (key < today) return "overdue"; + if (key === today) return "today"; + return "upcoming"; +} + +export function deadlineBadgeClass(tone: DeadlineTone): string { + switch (tone) { + case "upcoming": + return "border-blue-500/70 bg-blue-500/10 text-blue-800 dark:text-blue-200"; + case "today": + return "border-orange-500/80 bg-orange-500/15 text-orange-900 dark:text-orange-100"; + case "overdue": + return "border-destructive/80 bg-destructive/10 text-destructive"; + default: + return ""; + } +} + +export function formatDeadlineLabel(deadlineAt: string): string { + const key = deadlineToDateInput(deadlineAt); + if (!key) return deadlineAt; + try { + const [y, m, d] = key.split("-").map(Number); + const dt = new Date(y, m - 1, d); + return dt.toLocaleDateString(undefined, { dateStyle: "medium" }); + } catch { + return key; + } +} diff --git a/src/server/db.ts b/src/server/db.ts index f874a3a..31a7a3f 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -44,6 +44,12 @@ function migrate(database: Database) { } database.run(`CREATE INDEX IF NOT EXISTS idx_tag_tree_parent ON tag_tree_nodes(parent_id);`); database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`); + try { + database.run(`ALTER TABLE snippets ADD COLUMN deadline_at TEXT`); + } catch { + /* Spalte existiert bereits */ + } + database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_deadline ON snippets(deadline_at);`); } export function getDb(): Database { diff --git a/src/server/routes/snippetsHandlers.ts b/src/server/routes/snippetsHandlers.ts index 24a9c08..850bfb8 100644 --- a/src/server/routes/snippetsHandlers.ts +++ b/src/server/routes/snippetsHandlers.ts @@ -1,5 +1,12 @@ import { readJson } from "@/server/http/readJson"; -import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "@/server/snippets"; +import { + createSnippet, + deleteSnippet, + getSnippetById, + listSnippets, + normalizeDeadlineAtInput, + updateSnippet, +} from "@/server/snippets"; export const snippetRoutes = { "/api/snippets": { @@ -16,11 +23,21 @@ export const snippetRoutes = { } const o = body as Record; const tags = Array.isArray(o.tags) ? o.tags.filter((t): t is string => typeof t === "string") : []; + let deadline_at: string | null | undefined; + if ("deadline_at" in o) { + try { + deadline_at = normalizeDeadlineAtInput(o.deadline_at); + } catch (e) { + const msg = e instanceof Error ? e.message : "deadline_at ungültig"; + return Response.json({ error: msg }, { status: 400 }); + } + } const snippet = createSnippet({ title: o.title === null ? null : typeof o.title === "string" ? o.title : undefined, body: typeof o.body === "string" ? o.body : "", tags, + deadline_at, }); return Response.json(snippet, { status: 201 }); }, @@ -53,6 +70,7 @@ export const snippetRoutes = { body?: string; tags?: string[]; sort_order?: number; + deadline_at?: string | null; } = {}; if ("title" in o) { patch.title = o.title === null ? null : String(o.title); @@ -66,6 +84,14 @@ export const snippetRoutes = { if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) { patch.sort_order = o.sort_order; } + if ("deadline_at" in o) { + try { + patch.deadline_at = normalizeDeadlineAtInput(o.deadline_at); + } catch (e) { + const msg = e instanceof Error ? e.message : "deadline_at ungültig"; + return Response.json({ error: msg }, { status: 400 }); + } + } const snippet = updateSnippet(id, patch); if (!snippet) { return Response.json({ error: "Not found" }, { status: 404 }); diff --git a/src/server/snippets.ts b/src/server/snippets.ts index 99f10d0..386a464 100644 --- a/src/server/snippets.ts +++ b/src/server/snippets.ts @@ -18,6 +18,7 @@ export type SnippetRow = { sort_order: number; created_at: string; updated_at: string; + deadline_at: string | null; tags: string[]; /** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */ tag_display: string[]; @@ -25,6 +26,17 @@ export type SnippetRow = { tag_colors: Record; }; +/** PATCH/POST: `undefined` = nicht ändern, `null` = entfernen. */ +export function normalizeDeadlineAtInput(raw: unknown): string | null | undefined { + if (raw === undefined) return undefined; + if (raw === null) return null; + if (typeof raw !== "string") throw new Error("deadline_at muss ein String (YYYY-MM-DD) sein"); + const t = raw.trim(); + if (t === "") return null; + if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) throw new Error("deadline_at muss YYYY-MM-DD sein"); + return t; +} + /** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */ function parseSearchQuery(raw: string): { defaultText: string | null; @@ -69,6 +81,10 @@ function orderClause(sort: string): string { return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC"; case "title_desc": return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC"; + case "deadline_asc": + return "ORDER BY s.deadline_at ASC NULLS LAST, s.updated_at DESC"; + case "deadline_desc": + return "ORDER BY s.deadline_at DESC NULLS LAST, s.updated_at DESC"; case "manual": return "ORDER BY s.sort_order ASC, s.updated_at DESC"; case "updated_desc": @@ -107,6 +123,7 @@ function rowToPublic( sort_order: number; created_at: string; updated_at: string; + deadline_at: string | null; }, tagsRaw: string[], ): SnippetRow { @@ -124,6 +141,7 @@ function rowToPublic( sort_order: row.sort_order, created_at: row.created_at, updated_at: row.updated_at, + deadline_at: row.deadline_at, tags, tag_display, tag_colors, @@ -142,12 +160,13 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[ sort_order: number; created_at: string; updated_at: string; + deadline_at: string | null; }[]; if (trimmed === "") { rows = database .query( - `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at + `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s ${order}`, ) @@ -229,7 +248,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[ if (conditions.length === 0) { rows = database .query( - `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at + `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s ${order}`, ) @@ -238,7 +257,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[ const where = conditions.join(" AND "); rows = database .query( - `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at + `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s WHERE ${where} ${order}`, @@ -256,7 +275,7 @@ export function getSnippetById(id: number): SnippetRow | null { const database = getDb(); const row = database .query( - `SELECT id, title, body, sort_order, created_at, updated_at FROM snippets WHERE id = ?`, + `SELECT id, title, body, sort_order, created_at, updated_at, deadline_at FROM snippets WHERE id = ?`, ) .get(id) as | { @@ -266,6 +285,7 @@ export function getSnippetById(id: number): SnippetRow | null { sort_order: number; created_at: string; updated_at: string; + deadline_at: string | null; } | undefined; if (!row) return null; @@ -300,6 +320,7 @@ export function createSnippet(input: { body?: string; tags?: string[]; sort_order?: number; + deadline_at?: string | null; }): SnippetRow { const database = getDb(); const t = nowIso(); @@ -311,10 +332,12 @@ export function createSnippet(input: { : String(input.title).trim(); const body = input.body ?? ""; const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now(); + const deadlineAt = + input.deadline_at === undefined ? null : input.deadline_at === null ? null : String(input.deadline_at).trim() || null; database.run( - `INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, - [title, body, sortOrder, t, t], + `INSERT INTO snippets (title, body, sort_order, created_at, updated_at, deadline_at) VALUES (?, ?, ?, ?, ?, ?)`, + [title, body, sortOrder, t, t, deadlineAt], ); const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number }; const newId = Number(idRow.id); @@ -329,6 +352,7 @@ export function updateSnippet( body?: string; tags?: string[]; sort_order?: number; + deadline_at?: string | null; }, ): SnippetRow | null { const database = getDb(); @@ -336,11 +360,12 @@ export function updateSnippet( if (!existing) return null; const current = database - .query(`SELECT title, body, sort_order FROM snippets WHERE id = ?`) + .query(`SELECT title, body, sort_order, deadline_at FROM snippets WHERE id = ?`) .get(id) as { title: string | null; body: string; sort_order: number; + deadline_at: string | null; }; const title = @@ -351,11 +376,12 @@ export function updateSnippet( : String(input.title).trim(); const body = input.body === undefined ? current.body : String(input.body); const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order; + const deadline_at = input.deadline_at === undefined ? current.deadline_at : input.deadline_at; const t = nowIso(); database.run( - `UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`, - [title, body, sort_order, t, id], + `UPDATE snippets SET title = ?, body = ?, sort_order = ?, deadline_at = ?, updated_at = ? WHERE id = ?`, + [title, body, sort_order, deadline_at, t, id], ); if (input.tags !== undefined) { diff --git a/src/types/snippet.ts b/src/types/snippet.ts index 30ed45a..cb81106 100644 --- a/src/types/snippet.ts +++ b/src/types/snippet.ts @@ -5,6 +5,8 @@ export type Snippet = { sort_order: number; created_at: string; updated_at: string; + /** Kalendertag `YYYY-MM-DD` oder `null` = keine Deadline / erledigt. */ + deadline_at: string | null; tags: string[]; /** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */ tag_display: string[];