feat: smart property display — type-aware rendering with display mode override (#83)

* design: add smart property display wireframes

Frames: date picker, boolean toggle, status badge, display mode override dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 01:12:40 +01:00
committed by GitHub
parent d6feccb91f
commit 7534b8f20c
6 changed files with 1141 additions and 29 deletions

View File

@@ -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 <input type='date'> 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": {}
}

View File

@@ -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<string, string> = {}
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> = {}): 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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ deadline: '2026-03-31' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('date-display')).toBeInTheDocument()
expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument()
})
it('renders hidden date picker input', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ deadline: '2026-03-31' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('date-picker-input')).toBeInTheDocument()
})
it('calls onUpdateProperty when date picker value changes', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ deadline: '2026-03-31' }}
onUpdateProperty={onUpdateProperty}
/>
)
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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
it('renders status badge for known status values', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ phase: 'Draft' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
})
describe('smart property display — boolean', () => {
it('renders boolean toggle for true values', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ published: true }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
})
it('renders boolean toggle for false values', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ archived: false }}
onUpdateProperty={onUpdateProperty}
/>
)
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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('display-mode-trigger')).toBeInTheDocument()
})
it('opens display mode menu on trigger click', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly' }}
onUpdateProperty={onUpdateProperty}
/>
)
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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly' }}
onUpdateProperty={onUpdateProperty}
/>
)
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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
})
})

View File

@@ -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<string, { bg: string; color: string }> = {
Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
@@ -20,6 +30,11 @@ const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
'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}
</span>
@@ -112,12 +120,114 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
<button
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={onToggle}
data-testid="boolean-toggle"
>
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
)
}
function DateValue({ value, onSave }: {
value: string; onSave: (newValue: string) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const formatted = formatDateValue(value)
const isoValue = toISODate(value)
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) onSave(e.target.value)
}
const handleClick = () => {
inputRef.current?.showPicker()
}
return (
<span className="relative inline-flex min-w-0 items-center" data-testid="date-display">
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={handleClick}
title={value}
>
{formatted}
</span>
<input
ref={inputRef}
type="date"
className="pointer-events-none absolute inset-0 h-full w-full opacity-0"
value={isoValue}
onChange={handleDateChange}
tabIndex={-1}
data-testid="date-picker-input"
/>
</span>
)
}
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<HTMLDivElement>(null)
const handleSelect = (mode: PropertyDisplayMode) => {
if (mode === autoMode) {
onSelect(propKey, null)
} else {
onSelect(propKey, mode)
}
setOpen(false)
}
return (
<div ref={containerRef} className="relative">
<button
className="flex h-4 w-4 items-center justify-center rounded border-none bg-transparent p-0 text-[10px] leading-none text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/prop:opacity-100"
onClick={() => setOpen(!open)}
title="Change display mode"
data-testid="display-mode-trigger"
>
{'\u25BE'}
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div
className="absolute right-0 top-full z-50 mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map(opt => (
<button
key={opt.value}
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
onClick={() => handleSelect(opt.value)}
data-testid={`display-mode-option-${opt.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === opt.value ? '\u2713' : ''}
</span>
{opt.label}
{opt.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
))}
</div>
</>
)}
</div>
)
}
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 <EditableValue {...editProps} />
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
switch (displayMode) {
case 'status':
return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
case 'date':
if (typeof value === 'string') {
return <DateValue value={value} onSave={(v) => onSave(propKey, v)} />
}
return <EditableValue {...editProps} />
case 'boolean':
if (typeof value === 'boolean') {
return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
}
return <EditableValue {...editProps} />
case 'url':
if (typeof value === 'string' && isUrlValue(value)) {
return <UrlValue {...editProps} />
}
return <EditableValue {...editProps} />
default:
if (typeof value === 'boolean') {
return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
}
if (typeof value === 'string' && isUrlValue(value)) {
return <UrlValue {...editProps} />
}
return <EditableValue {...editProps} />
}
}
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 (
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
@@ -219,27 +370,13 @@ function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveLi
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<PropertyValueCell propKey={propKey} value={value} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
)
}
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 <EditableValue {...editProps} />
if (isStatusKey(propKey)) return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
if (isDateKey(propKey)) return <EditableValue {...editProps} />
if (typeof value === 'boolean') return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
if (typeof value === 'string' && isUrlValue(value)) return <UrlValue {...editProps} />
return <EditableValue {...editProps} />
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
@@ -305,6 +442,7 @@ export function DynamicPropertiesPanel({
}) {
const [editingKey, setEditingKey] = useState<string | null>(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 (
<div className="flex flex-col gap-3">
{/* Editable properties section */}
<div className="flex flex-col gap-2">
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow key={key} propKey={key} value={value} editingKey={editingKey} onStartEdit={setEditingKey} onSave={handleSaveValue} onSaveList={handleSaveList} onUpdate={onUpdateProperty} onDelete={onDeleteProperty} />
))}
{propertyEntries.map(([key, value]) => {
const autoMode = detectPropertyType(key, value)
const effectiveMode = getEffectiveDisplayMode(key, value, displayOverrides)
return (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={effectiveMode} autoMode={autoMode}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
)
})}
</div>
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} />

View File

@@ -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

View File

@@ -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<string, string> = {}
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')
})
})

View File

@@ -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<string, PropertyDisplayMode> {
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<string, PropertyDisplayMode>,
): 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
}