From 7534b8f20c74c8367e490dbd4f5b48235663dda0 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 26 Feb 2026 01:12:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20smart=20property=20display=20=E2=80=94?= =?UTF-8?q?=20type-aware=20rendering=20with=20display=20mode=20override=20?= =?UTF-8?q?(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: add smart property display wireframes Frames: date picker, boolean toggle, status badge, display mode override dropdown. Co-Authored-By: Claude Opus 4.6 * feat: smart property display — type-aware rendering with display mode override - Add property type auto-detection (date, boolean, status, url, text) based on value content and property name - Date properties (ISO strings) show as friendly format (e.g. "Mar 31, 2026") with native date picker on click - Boolean properties show as toggle buttons - Status properties auto-detected from key name or known status values, rendered as colored badges - Each property gets a display mode override dropdown (visible on hover) to force a specific display type - Display mode overrides persist across sessions via localStorage - Add mock data with date/boolean fields for testing - 36 new tests covering type detection, date formatting, localStorage persistence, and UI rendering Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- design/smart-property-display.pen | 524 ++++++++++++++++++ .../DynamicPropertiesPanel.test.tsx | 175 ++++++ src/components/DynamicPropertiesPanel.tsx | 216 +++++++- src/mock-tauri/mock-content.ts | 3 + src/utils/propertyTypes.test.ts | 155 ++++++ src/utils/propertyTypes.ts | 97 ++++ 6 files changed, 1141 insertions(+), 29 deletions(-) create mode 100644 design/smart-property-display.pen create mode 100644 src/utils/propertyTypes.test.ts create mode 100644 src/utils/propertyTypes.ts diff --git a/design/smart-property-display.pen b/design/smart-property-display.pen new file mode 100644 index 00000000..482e3654 --- /dev/null +++ b/design/smart-property-display.pen @@ -0,0 +1,524 @@ +{ + "children": [ + { + "type": "frame", + "id": "spd-date", + "x": 0, + "y": 0, + "name": "Smart Properties — Date picker open", + "width": 280, + "height": 300, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-date-title", + "content": "Date Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-date-row", + "name": "DateRow", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-date-label", + "content": "DEADLINE", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2, + "textTransform": "uppercase" + }, + { + "type": "text", + "id": "spd-date-value", + "content": "Feb 25, 2026", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-date-picker", + "name": "DatePickerOpen", + "width": "fill_container", + "height": 200, + "fill": "#F5F5F5", + "cornerRadius": 8, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-picker-month", + "content": "February 2026", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "text", + "id": "spd-picker-hint", + "content": "Native opens on click.\nDisplays as 'Feb 25, 2026' when closed.", + "fontSize": 11, + "fill": "#888888" + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-bool", + "x": 300, + "y": 0, + "name": "Smart Properties — Boolean toggle", + "width": 280, + "height": 200, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-bool-title", + "content": "Boolean Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-bool-row-on", + "name": "BooleanRowTrue", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-bool-label-on", + "content": "PUBLISHED", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-bool-toggle-on", + "name": "ToggleOn", + "height": 22, + "cornerRadius": 4, + "fill": "#E8F5E9", + "padding": [2, 8], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-bool-val-on", + "content": "\u2713 Yes", + "fontSize": 12, + "fill": "#2E7D32" + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-bool-row-off", + "name": "BooleanRowFalse", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-bool-label-off", + "content": "ARCHIVED", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-bool-toggle-off", + "name": "ToggleOff", + "height": 22, + "cornerRadius": 4, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "padding": [2, 8], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-bool-val-off", + "content": "\u2717 No", + "fontSize": 12, + "fill": "#888888" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status", + "x": 600, + "y": 0, + "name": "Smart Properties — Status badge", + "width": 280, + "height": 260, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-status-title", + "content": "Status Property Display", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-status-row-active", + "name": "StatusActive", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-a", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-active", + "name": "BadgeActive", + "cornerRadius": 16, + "fill": "#E8F5E9", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-active-text", + "content": "ACTIVE", + "fontSize": 10, + "fontWeight": "600", + "fill": "#2E7D32", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status-row-draft", + "name": "StatusDraft", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-d", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-draft", + "name": "BadgeDraft", + "cornerRadius": 16, + "fill": "#FFF8E1", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-draft-text", + "content": "DRAFT", + "fontSize": 10, + "fontWeight": "600", + "fill": "#F9A825", + "letterSpacing": 1.2 + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-status-row-done", + "name": "StatusDone", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "children": [ + { + "type": "text", + "id": "spd-status-label-dn", + "content": "STATUS", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-badge-done", + "name": "BadgeDone", + "cornerRadius": 16, + "fill": "#E3F2FD", + "padding": [1, 6], + "children": [ + { + "type": "text", + "id": "spd-badge-done-text", + "content": "DONE", + "fontSize": 10, + "fontWeight": "600", + "fill": "#1565C0", + "letterSpacing": 1.2 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-override", + "x": 900, + "y": 0, + "name": "Smart Properties — Display mode override dropdown", + "width": 280, + "height": 300, + "fill": "#FFFFFF", + "layout": "vertical", + "padding": 12, + "gap": 8, + "children": [ + { + "type": "text", + "id": "spd-override-title", + "content": "Display Mode Override", + "fontSize": 13, + "fontWeight": "600", + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-override-row", + "name": "PropertyWithOverrideHandle", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "justifyContent": "space-between", + "padding": [0, 6], + "fill": "#F5F5F5", + "cornerRadius": 4, + "children": [ + { + "type": "text", + "id": "spd-override-label", + "content": "DEADLINE", + "fontSize": 10, + "fontWeight": "500", + "fill": "#888888", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "spd-override-actions", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-override-val", + "content": "Feb 25, 2026", + "fontSize": 12, + "fill": "#1A1A1A" + }, + { + "type": "frame", + "id": "spd-override-handle", + "name": "OverrideHandle", + "width": 16, + "height": 16, + "cornerRadius": 3, + "fill": "#E0E0E0", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "spd-override-icon", + "content": "\u25BE", + "fontSize": 10, + "fill": "#666666" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "spd-dropdown", + "name": "DisplayModeDropdown", + "width": 160, + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { "fill": "#E0E0E0", "thickness": 1 }, + "layout": "vertical", + "padding": 4, + "gap": 2, + "children": [ + { + "type": "frame", + "id": "spd-opt-text", + "name": "OptionText", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-text-label", + "content": "Text", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-date", + "name": "OptionDate", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "fill": "#E3F2FD", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-date-label", + "content": "\u2713 Date", + "fontSize": 12, + "fill": "#1565C0" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-bool", + "name": "OptionBoolean", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-bool-label", + "content": "Boolean", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-status", + "name": "OptionStatus", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-status-label", + "content": "Status", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + }, + { + "type": "frame", + "id": "spd-opt-url", + "name": "OptionURL", + "width": "fill_container", + "height": 28, + "padding": [4, 8], + "cornerRadius": 4, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "spd-opt-url-label", + "content": "URL", + "fontSize": 12, + "fill": "#1A1A1A" + } + ] + } + ] + } + ] + } + ], + "variables": {} +} diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 358b450d..a7dceae9 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -3,6 +3,20 @@ import { render, screen, fireEvent } from '@testing-library/react' import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel' import type { VaultEntry } from '../types' +// Mock localStorage (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (i: number) => Object.keys(store)[i] ?? null, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + // Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM beforeAll(() => { global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } @@ -10,6 +24,8 @@ beforeAll(() => { Element.prototype.hasPointerCapture = () => false Element.prototype.setPointerCapture = vi.fn() Element.prototype.releasePointerCapture = vi.fn() + // showPicker is not available in JSDOM + HTMLInputElement.prototype.showPicker = vi.fn() }) const makeEntry = (overrides: Partial = {}): VaultEntry => ({ @@ -658,4 +674,163 @@ describe('DynamicPropertiesPanel', () => { expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() }) }) + + describe('smart property display — date', () => { + it('renders date property with friendly format', () => { + render( + + ) + expect(screen.getByTestId('date-display')).toBeInTheDocument() + expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument() + }) + + it('renders hidden date picker input', () => { + render( + + ) + expect(screen.getByTestId('date-picker-input')).toBeInTheDocument() + }) + + it('calls onUpdateProperty when date picker value changes', () => { + render( + + ) + const dateInput = screen.getByTestId('date-picker-input') + fireEvent.change(dateInput, { target: { value: '2026-04-15' } }) + expect(onUpdateProperty).toHaveBeenCalledWith('deadline', '2026-04-15') + }) + }) + + describe('smart property display — status auto-detection', () => { + it('renders status badge for property named Status', () => { + render( + + ) + expect(screen.getByTestId('status-badge')).toBeInTheDocument() + }) + + it('renders status badge for known status values', () => { + render( + + ) + expect(screen.getByTestId('status-badge')).toBeInTheDocument() + }) + }) + + describe('smart property display — boolean', () => { + it('renders boolean toggle for true values', () => { + render( + + ) + expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() + expect(screen.getByText('\u2713 Yes')).toBeInTheDocument() + }) + + it('renders boolean toggle for false values', () => { + render( + + ) + expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() + expect(screen.getByText('\u2717 No')).toBeInTheDocument() + }) + }) + + describe('display mode override', () => { + beforeEach(() => { + localStorageMock.clear() + }) + + it('renders display mode trigger on property rows', () => { + render( + + ) + expect(screen.getByTestId('display-mode-trigger')).toBeInTheDocument() + }) + + it('opens display mode menu on trigger click', () => { + render( + + ) + fireEvent.click(screen.getByTestId('display-mode-trigger')) + expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument() + expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument() + expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument() + expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument() + expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument() + expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument() + }) + + it('persists override to localStorage when mode selected', () => { + render( + + ) + fireEvent.click(screen.getByTestId('display-mode-trigger')) + fireEvent.click(screen.getByTestId('display-mode-option-status')) + const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}') + expect(stored.cadence).toBe('status') + }) + + it('overrides rendering to status badge when status mode selected', () => { + localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' })) + render( + + ) + expect(screen.getByTestId('status-badge')).toBeInTheDocument() + }) + }) }) diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index b25e3812..6635503d 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useCallback } from 'react' +import { useMemo, useState, useCallback, useRef } from 'react' import type { VaultEntry } from '../types' import type { FrontmatterValue } from './Inspector' import type { ParsedFrontmatter } from '../utils/frontmatter' @@ -8,6 +8,16 @@ import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { countWords } from '../utils/wikilinks' +import { + type PropertyDisplayMode, + getEffectiveDisplayMode, + formatDateValue, + toISODate, + loadDisplayModeOverrides, + saveDisplayModeOverride, + removeDisplayModeOverride, + detectPropertyType, +} from '../utils/propertyTypes' const STATUS_STYLES: Record = { Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, @@ -20,6 +30,11 @@ const STATUS_STYLES: Record = { 'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + 'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' }, + Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, } const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' } @@ -68,14 +83,6 @@ function formatFileSize(bytes: number): string { return `${mb.toFixed(1)} MB` } -function isStatusKey(key: string): boolean { - return key === 'Status' || key.includes('Status') -} - -function isDateKey(key: string): boolean { - return key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date') -} - function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: { propKey: string; value: FrontmatterValue; isEditing: boolean onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void @@ -101,6 +108,7 @@ function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: { className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80" style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }} onClick={() => onStartEdit(propKey)} title={statusStr} + data-testid="status-badge" > {statusStr} @@ -112,12 +120,114 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo ) } +function DateValue({ value, onSave }: { + value: string; onSave: (newValue: string) => void +}) { + const inputRef = useRef(null) + const formatted = formatDateValue(value) + const isoValue = toISODate(value) + + const handleDateChange = (e: React.ChangeEvent) => { + if (e.target.value) onSave(e.target.value) + } + + const handleClick = () => { + inputRef.current?.showPicker() + } + + return ( + + + {formatted} + + + + ) +} + +const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [ + { value: 'text', label: 'Text' }, + { value: 'date', label: 'Date' }, + { value: 'boolean', label: 'Boolean' }, + { value: 'status', label: 'Status' }, + { value: 'url', label: 'URL' }, +] + +function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: { + propKey: string; currentMode: PropertyDisplayMode; autoMode: PropertyDisplayMode + onSelect: (key: string, mode: PropertyDisplayMode | null) => void +}) { + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + const handleSelect = (mode: PropertyDisplayMode) => { + if (mode === autoMode) { + onSelect(propKey, null) + } else { + onSelect(propKey, mode) + } + setOpen(false) + } + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {DISPLAY_MODE_OPTIONS.map(opt => ( + + ))} +
+ + )} +
+ ) +} + function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: string) => void; onCancel: () => void }) { const [newKey, setNewKey] = useState('') const [newValue, setNewValue] = useState('') @@ -206,11 +316,52 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o ) } -function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveList, onUpdate, onDelete }: { +function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: { + propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean + onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void + onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void +}) { + const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) } + + if (value === null || value === undefined) return + if (Array.isArray(value)) return onSaveList(propKey, items)} label={propKey} /> + + switch (displayMode) { + case 'status': + return + case 'date': + if (typeof value === 'string') { + return onSave(propKey, v)} /> + } + return + case 'boolean': + if (typeof value === 'boolean') { + return onUpdate?.(propKey, !value)} /> + } + return + case 'url': + if (typeof value === 'string' && isUrlValue(value)) { + return + } + return + default: + if (typeof value === 'boolean') { + return onUpdate?.(propKey, !value)} /> + } + if (typeof value === 'string' && isUrlValue(value)) { + return + } + return + } +} + +function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: { propKey: string; value: FrontmatterValue; editingKey: string | null + displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void + onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void }) { return (
@@ -219,27 +370,13 @@ function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveLi {onDelete && ( )} + - +
) } -function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: { - propKey: string; value: FrontmatterValue; isEditing: boolean - onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void - onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void -}) { - const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) } - if (value === null || value === undefined) return - if (isStatusKey(propKey)) return - if (Array.isArray(value)) return onSaveList(propKey, items)} label={propKey} /> - if (isDateKey(propKey)) return - if (typeof value === 'boolean') return onUpdate?.(propKey, !value)} /> - if (typeof value === 'string' && isUrlValue(value)) return - return -} - function InfoRow({ label, value }: { label: string; value: string }) { return (
@@ -305,6 +442,7 @@ export function DynamicPropertiesPanel({ }) { const [editingKey, setEditingKey] = useState(null) const [showAddDialog, setShowAddDialog] = useState(false) + const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides()) const wordCount = countWords(content ?? '') @@ -337,14 +475,34 @@ export function DynamicPropertiesPanel({ setShowAddDialog(false) }, [onAddProperty]) + const handleDisplayModeChange = useCallback((key: string, mode: PropertyDisplayMode | null) => { + if (mode === null) { + removeDisplayModeOverride(key) + } else { + saveDisplayModeOverride(key, mode) + } + setDisplayOverrides(loadDisplayModeOverrides()) + }, []) + return (
{/* Editable properties section */}
- {propertyEntries.map(([key, value]) => ( - - ))} + {propertyEntries.map(([key, value]) => { + const autoMode = detectPropertyType(key, value) + const effectiveMode = getEffectiveDisplayMode(key, value, displayOverrides) + return ( + + ) + })}
{showAddDialog ? setShowAddDialog(false)} /> diff --git a/src/mock-tauri/mock-content.ts b/src/mock-tauri/mock-content.ts index 24e4a038..453547ad 100644 --- a/src/mock-tauri/mock-content.ts +++ b/src/mock-tauri/mock-content.ts @@ -9,6 +9,9 @@ title: Build Laputa App type: Project status: Active owner: Luca Rossi +deadline: 2026-03-31 +published: true +archived: false tags: [Tauri, React, TypeScript, CodeMirror] tools: [Vite, Vitest, Playwright] url: https://github.com/lucaong/laputa-app diff --git a/src/utils/propertyTypes.test.ts b/src/utils/propertyTypes.test.ts new file mode 100644 index 00000000..ae70c0c7 --- /dev/null +++ b/src/utils/propertyTypes.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + detectPropertyType, + formatDateValue, + toISODate, + getEffectiveDisplayMode, + loadDisplayModeOverrides, + saveDisplayModeOverride, + removeDisplayModeOverride, +} from './propertyTypes' + +// Mock localStorage (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (i: number) => Object.keys(store)[i] ?? null, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +describe('detectPropertyType', () => { + it('detects boolean from value type', () => { + expect(detectPropertyType('archived', true)).toBe('boolean') + expect(detectPropertyType('published', false)).toBe('boolean') + }) + + it('detects status from key name', () => { + expect(detectPropertyType('status', 'Active')).toBe('status') + expect(detectPropertyType('Status', 'Draft')).toBe('status') + }) + + it('detects status from known status values', () => { + expect(detectPropertyType('phase', 'active')).toBe('status') + expect(detectPropertyType('state', 'done')).toBe('status') + expect(detectPropertyType('progress', 'in progress')).toBe('status') + expect(detectPropertyType('result', 'published')).toBe('status') + }) + + it('detects date from ISO string', () => { + expect(detectPropertyType('deadline', '2026-03-31')).toBe('date') + expect(detectPropertyType('due_date', '2026-01-15T10:00')).toBe('date') + }) + + it('detects date from date-like key names with date value', () => { + expect(detectPropertyType('start_date', '2026-06-01')).toBe('date') + expect(detectPropertyType('scheduled', '2026-02-25')).toBe('date') + }) + + it('detects date from ISO string even without date key', () => { + expect(detectPropertyType('custom_field', '2026-03-31')).toBe('date') + }) + + it('returns text for plain strings', () => { + expect(detectPropertyType('owner', 'Luca Rossi')).toBe('text') + expect(detectPropertyType('cadence', 'Weekly')).toBe('text') + }) + + it('returns text for null/undefined', () => { + expect(detectPropertyType('anything', null)).toBe('text') + expect(detectPropertyType('anything', undefined as never)).toBe('text') + }) + + it('returns text for arrays', () => { + expect(detectPropertyType('tags', ['a', 'b'])).toBe('text') + }) + + it('treats date-keyed fields with non-date values as text', () => { + expect(detectPropertyType('deadline', 'active')).toBe('text') + }) + + it('detects common date format MM/DD/YYYY', () => { + expect(detectPropertyType('due', '02/25/2026')).toBe('date') + }) +}) + +describe('formatDateValue', () => { + it('formats ISO date to friendly format', () => { + const result = formatDateValue('2026-03-31') + expect(result).toBe('Mar 31, 2026') + }) + + it('formats ISO datetime', () => { + const result = formatDateValue('2026-02-25T10:00') + expect(result).toBe('Feb 25, 2026') + }) + + it('formats MM/DD/YYYY', () => { + const result = formatDateValue('02/25/2026') + expect(result).toBe('Feb 25, 2026') + }) + + it('returns original value for non-date strings', () => { + expect(formatDateValue('not a date')).toBe('not a date') + }) +}) + +describe('toISODate', () => { + it('converts ISO date to YYYY-MM-DD', () => { + expect(toISODate('2026-03-31')).toBe('2026-03-31') + }) + + it('extracts date from ISO datetime', () => { + expect(toISODate('2026-02-25T10:00:00')).toBe('2026-02-25') + }) + + it('returns original value for non-date strings', () => { + expect(toISODate('not a date')).toBe('not a date') + }) +}) + +describe('display mode overrides (localStorage)', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('returns empty object when no overrides saved', () => { + expect(loadDisplayModeOverrides()).toEqual({}) + }) + + it('saves and loads an override', () => { + saveDisplayModeOverride('deadline', 'date') + const overrides = loadDisplayModeOverrides() + expect(overrides.deadline).toBe('date') + }) + + it('removes an override', () => { + saveDisplayModeOverride('deadline', 'date') + removeDisplayModeOverride('deadline') + expect(loadDisplayModeOverrides()).toEqual({}) + }) + + it('handles corrupted localStorage gracefully', () => { + localStorage.setItem('laputa:display-mode-overrides', 'not valid json') + expect(loadDisplayModeOverrides()).toEqual({}) + }) +}) + +describe('getEffectiveDisplayMode', () => { + it('uses auto-detected mode when no override', () => { + expect(getEffectiveDisplayMode('status', 'Active', {})).toBe('status') + }) + + it('uses override when present', () => { + expect(getEffectiveDisplayMode('status', 'Active', { status: 'text' })).toBe('text') + }) + + it('prefers override over auto-detection', () => { + expect(getEffectiveDisplayMode('deadline', '2026-03-31', { deadline: 'text' })).toBe('text') + }) +}) diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts new file mode 100644 index 00000000..26b7923a --- /dev/null +++ b/src/utils/propertyTypes.ts @@ -0,0 +1,97 @@ +import type { FrontmatterValue } from '../components/Inspector' + +export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/ +const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/ + +const STATUS_VALUES = new Set([ + 'active', 'done', 'paused', 'archived', 'dropped', + 'open', 'closed', 'not started', 'draft', 'mixed', + 'published', 'in progress', 'blocked', 'cancelled', 'pending', +]) + +const STATUS_KEY_PATTERNS = ['status'] +const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled'] + +function keyMatchesPatterns(key: string, patterns: string[]): boolean { + const lower = key.toLowerCase() + return patterns.some(p => lower === p || lower.includes(p)) +} + +function isDateString(value: string): boolean { + return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value) +} + +export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode { + if (value === null || value === undefined) return 'text' + if (typeof value === 'boolean') return 'boolean' + if (Array.isArray(value)) return 'text' + + const strValue = String(value) + + if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status' + if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status' + if (keyMatchesPatterns(key, DATE_KEY_PATTERNS) && isDateString(strValue)) return 'date' + if (isDateString(strValue)) return 'date' + + return 'text' +} + +const STORAGE_KEY = 'laputa:display-mode-overrides' + +export function loadDisplayModeOverrides(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +export function saveDisplayModeOverride(propertyName: string, mode: PropertyDisplayMode): void { + const overrides = loadDisplayModeOverrides() + overrides[propertyName] = mode + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) +} + +export function removeDisplayModeOverride(propertyName: string): void { + const overrides = loadDisplayModeOverrides() + delete overrides[propertyName] + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) +} + +export function getEffectiveDisplayMode( + key: string, + value: FrontmatterValue, + overrides: Record, +): PropertyDisplayMode { + return overrides[key] ?? detectPropertyType(key, value) +} + +export function formatDateValue(value: string): string { + const isoMatch = value.match(ISO_DATE_RE) + if (isoMatch) { + const d = new Date(isoMatch[0]) + if (!isNaN(d.getTime())) { + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } + } + const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/) + if (parts) { + const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2])) + if (!isNaN(d.getTime())) { + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } + } + return value +} + +export function toISODate(value: string): string { + const isoMatch = value.match(ISO_DATE_RE) + if (isoMatch) { + const d = new Date(isoMatch[0]) + if (!isNaN(d.getTime())) return d.toISOString().split('T')[0] + } + return value +}