From 8ca3b81e92599726004b14ff5f6f0c4ab153521c Mon Sep 17 00:00:00 2001
From: Your Name
Date: Mon, 16 Feb 2026 14:33:48 +0200
Subject: [PATCH] feat: Phase 1 quick wins - defaultColumnWidth, due date
colors, card aging, open attachments
---
src/components/board/CardThumbnail.tsx | 222 ++++++++++++++++--
.../card-detail/AttachmentSection.tsx | 10 +-
src/stores/board-store.ts | 4 +-
3 files changed, 211 insertions(+), 25 deletions(-)
diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx
index 01ad8d4..1d03873 100644
--- a/src/components/board/CardThumbnail.tsx
+++ b/src/components/board/CardThumbnail.tsx
@@ -1,14 +1,54 @@
-import React from "react";
-import { format, isPast, isToday } from "date-fns";
-import { motion, useReducedMotion } from "framer-motion";
+import { useState, useRef } from "react";
+import { createPortal } from "react-dom";
+import { format } from "date-fns";
+import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
import { fadeSlideUp, springs } from "@/lib/motion";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Card, Label } from "@/types/board";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar";
import { Paperclip, AlignLeft } from "lucide-react";
+/* ---------- Due date status ---------- */
+
+function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
+ if (!dueDate) return null;
+ const date = new Date(dueDate);
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+
+ if (diffDays < 0) {
+ return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
+ }
+ if (diffDays <= 2) {
+ return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
+ }
+ return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
+}
+
+/* ---------- Card aging ---------- */
+
+function getAgingOpacity(updatedAt: string): number {
+ const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
+ if (days <= 7) return 1.0;
+ if (days <= 14) return 0.85;
+ if (days <= 30) return 0.7;
+ return 0.55;
+}
+
+/* ---------- Priority colors ---------- */
+
+const PRIORITY_COLORS: Record = {
+ low: "oklch(60% 0.15 240)",
+ medium: "oklch(70% 0.15 85)",
+ high: "oklch(60% 0.15 55)",
+ urgent: "oklch(55% 0.15 25)",
+};
+
interface CardThumbnailProps {
card: Card;
boardLabels: Label[];
@@ -31,25 +71,40 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
data: { type: "card", columnId },
});
- const style: React.CSSProperties = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.3 : undefined,
- padding: `calc(0.75rem * var(--density-factor))`,
- };
-
const hasDueDate = card.dueDate != null;
- const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
- const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
+ const dueDateStatus = getDueDateStatus(card.dueDate);
function handleClick() {
onCardClick?.(card.id);
}
+ // Drop indicator line when this card is being dragged
+ if (isDragging) {
+ return (
+
+ );
+ }
+
return (
{card.title}
- {/* Footer row: due date + checklist */}
- {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && (
+ {/* Footer row: priority + due date + checklist + icons */}
+ {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
- {dueDate && (
+ {card.priority !== "none" && (
+ )}
+ {dueDateStatus && card.dueDate && (
+
- {format(dueDate, "MMM d")}
+ {format(new Date(card.dueDate), "MMM d")}
)}
{card.checklist.length > 0 && (
)}
{card.description && (
-
+
)}
{card.attachments.length > 0 && (
@@ -117,3 +176,120 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
);
}
+
+/* ---------- Description hover preview ---------- */
+
+function DescriptionPreview({ description }: { description: string }) {
+ const [show, setShow] = useState(false);
+ const [pos, setPos] = useState<{ below: boolean; top?: number; bottom?: number; left: number; arrowLeft: number; maxHeight: number } | null>(null);
+ const timeoutRef = useRef | null>(null);
+ const iconRef = useRef(null);
+
+ function handleEnter() {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ if (iconRef.current) {
+ const rect = iconRef.current.getBoundingClientRect();
+ const zoom = parseFloat(getComputedStyle(document.documentElement).fontSize) / 16;
+ const popupW = 224 * zoom; // w-56 = 14rem, scales with zoom
+ const gap = 8;
+
+ // Flip below if more room below than above
+ const titleBarH = 52 * zoom;
+ const spaceAbove = rect.top - gap - titleBarH;
+ const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
+ const below = spaceBelow > spaceAbove;
+
+ // Max height for popup content (stay within viewport)
+ const paddingPx = 24 * zoom; // p-3 top + bottom
+ const maxAvailable = below
+ ? window.innerHeight - (rect.bottom + gap) - 8
+ : rect.top - gap - titleBarH;
+ const maxHeight = Math.max(60, Math.min(maxAvailable - paddingPx, 300 * zoom));
+
+ // Center horizontally, clamp to viewport
+ let left = rect.left + rect.width / 2 - popupW / 2;
+ left = Math.max(8, Math.min(left, window.innerWidth - popupW - 8));
+
+ // Arrow offset relative to popup left edge
+ const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, popupW - 12));
+
+ // Position: use top when below, bottom when above (avoids height estimation)
+ setPos(below
+ ? { below: true, top: rect.bottom + gap, left, arrowLeft, maxHeight }
+ : { below: false, bottom: window.innerHeight - rect.top + gap, left, arrowLeft, maxHeight }
+ );
+ }
+ setShow(true);
+ }, 300);
+ }
+
+ function handleLeave() {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setShow(false), 100);
+ }
+
+ function cancelHide() {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ }
+
+ // Strip markdown formatting for a clean preview
+ const plainText = description
+ .replace(/^#{1,6}\s+/gm, "")
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/\*(.+?)\*/g, "$1")
+ .replace(/`(.+?)`/g, "$1")
+ .replace(/\[(.+?)\]\(.+?\)/g, "$1")
+ .replace(/^[-*]\s+/gm, "- ")
+ .trim();
+
+
+ return (
+
+
+ {createPortal(
+
+ {show && pos && (
+ setShow(false)}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {/* Arrow */}
+
+
+
+ {plainText}
+
+
+
+ )}
+ ,
+ document.body
+ )}
+
+ );
+}
diff --git a/src/components/card-detail/AttachmentSection.tsx b/src/components/card-detail/AttachmentSection.tsx
index 38d744d..56af145 100644
--- a/src/components/card-detail/AttachmentSection.tsx
+++ b/src/components/card-detail/AttachmentSection.tsx
@@ -1,4 +1,5 @@
-import { FileIcon, X, Plus } from "lucide-react";
+import { FileIcon, X, Plus, ExternalLink } from "lucide-react";
+import { openPath } from "@tauri-apps/plugin-opener";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store";
@@ -68,6 +69,13 @@ export function AttachmentSection({
{att.name}
+