+
+ {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
+}