Compare commits

...

6 Commits

Author SHA1 Message Date
yyh
a0aa8cdb45 Merge remote-tracking branch 'origin/main' into feature/task-quadrant-view 2026-01-16 18:20:29 +08:00
yyh
ae8618877b fix(web): quadrant matrix i18n 2026-01-16 18:17:28 +08:00
yyh
1c55602445 fix(web): add calendar icon and DDL label to deadline badge in task-item 2026-01-16 17:24:11 +08:00
yyh
a3f1220d23 feat(web): add fullscreen expand mode to quadrant-matrix component
- Add expand button in header to open FullScreenModal
- Add numbered circles (1-4) to quadrant headers
- Add expanded prop to show full content without line-clamp
- Reorder grid layout: Q1 top-left, Q2 top-right, Q3 bottom-left, Q4 bottom-right
- Remove axis labels for cleaner design
2026-01-16 17:16:13 +08:00
yyh
d62e16b9bb fix(web): improve quadrant-matrix layout and text overflow handling
- Simplify axis label layout with horizontal/vertical arrangement
- Add proper text truncation with line-clamp and tooltips
- Fix overflow issues by adding min-w-0 on flex children
- Move scores inline with task name for compact display
- Add task count badge to quadrant headers
- Reduce maxDisplay to 3 for better density
2026-01-16 16:58:57 +08:00
yyh
13f2a43ccc feat(web): add Eisenhower Matrix visualization component for task quadrants
Add a new quadrant-matrix component that renders tasks in a 2x2 grid based
on importance and urgency scores. Integrate with code-block as a new
'quadrant' language type for markdown rendering.
2026-01-16 16:58:56 +08:00
7 changed files with 479 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import { Theme } from '@/types/app'
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
const QuadrantMatrix = dynamic(() => import('@/app/components/base/quadrant-matrix'), { ssr: false })
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
const capitalizationLanguageNameMap: Record<string, string> = {
@@ -40,6 +41,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
latex: 'Latex',
svg: 'SVG',
abc: 'ABC',
quadrant: 'Quadrant',
}
const getCorrectCapitalizationLanguageName = (language: string) => {
if (!language)
@@ -409,6 +411,12 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
<MarkdownMusic children={content} />
</ErrorBoundary>
)
case 'quadrant':
return (
<ErrorBoundary>
<QuadrantMatrix content={content} />
</ErrorBoundary>
)
default:
return (
<SyntaxHighlighter

View File

@@ -0,0 +1,153 @@
'use client'
import type { FC } from 'react'
import type { QuadrantData } from './types'
import { RiExpandDiagonalLine } from '@remixicon/react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import FullScreenModal from '@/app/components/base/fullscreen-modal'
import QuadrantCard from './quadrant-card'
import { isValidQuadrantData, QUADRANT_CONFIGS } from './types'
type QuadrantMatrixProps = {
content: string
}
const QuadrantMatrix: FC<QuadrantMatrixProps> = ({ content }) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const parsedData = useMemo<QuadrantData | null>(() => {
try {
const trimmed = content.trim()
const data = JSON.parse(trimmed)
if (!isValidQuadrantData(data))
return null
return data
}
catch {
return null
}
}, [content])
const handleExpand = useCallback(() => {
setIsExpanded(true)
}, [])
const handleClose = useCallback(() => {
setIsExpanded(false)
}, [])
if (!parsedData) {
return (
<div className="flex items-center justify-center rounded-xl bg-components-panel-bg-blur p-8">
<div className="text-center text-text-secondary">
<div className="system-md-semibold mb-2">{t('quadrantMatrix.invalidData', { ns: 'app' })}</div>
<div className="text-sm text-text-tertiary">
{t('quadrantMatrix.invalidDataDesc', { ns: 'app' })}
</div>
</div>
</div>
)
}
const totalTasks
= parsedData.q1.length
+ parsedData.q2.length
+ parsedData.q3.length
+ parsedData.q4.length
// Shared grid content component
const renderGrid = (expanded: boolean) => (
<div className="grid grid-cols-2 gap-3">
{/* Row 1: Q1 (Do First), Q2 (Schedule) */}
<QuadrantCard
config={QUADRANT_CONFIGS.q1}
tasks={parsedData.q1}
expanded={expanded}
/>
<QuadrantCard
config={QUADRANT_CONFIGS.q2}
tasks={parsedData.q2}
expanded={expanded}
/>
{/* Row 2: Q3 (Delegate), Q4 (Don't Do) */}
<QuadrantCard
config={QUADRANT_CONFIGS.q3}
tasks={parsedData.q3}
expanded={expanded}
/>
<QuadrantCard
config={QUADRANT_CONFIGS.q4}
tasks={parsedData.q4}
expanded={expanded}
/>
</div>
)
return (
<>
<div className="w-full overflow-hidden rounded-xl bg-components-panel-bg-blur p-4">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div>
<div className="system-md-semibold text-text-primary">
{t('quadrantMatrix.title', { ns: 'app' })}
</div>
<div className="text-xs text-text-tertiary">
{t('quadrantMatrix.taskCount', { ns: 'app', count: totalTasks })}
</div>
</div>
{/* Legend + Expand Button */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 text-[11px] text-text-quaternary">
<span>{t('quadrantMatrix.legend.importance', { ns: 'app' })}</span>
<span>{t('quadrantMatrix.legend.urgency', { ns: 'app' })}</span>
</div>
<ActionButton onClick={handleExpand}>
<RiExpandDiagonalLine className="h-4 w-4" />
</ActionButton>
</div>
</div>
{/* 2x2 Grid */}
{renderGrid(false)}
</div>
{/* Fullscreen Modal */}
<FullScreenModal
open={isExpanded}
onClose={handleClose}
closable
>
<div className="flex h-full flex-col p-6">
{/* Modal Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<div className="text-xl font-semibold text-text-primary">
{t('quadrantMatrix.title', { ns: 'app' })}
</div>
<div className="text-sm text-text-tertiary">
{t('quadrantMatrix.taskCount', { ns: 'app', count: totalTasks })}
</div>
</div>
<div className="flex items-center gap-3 text-sm text-text-quaternary">
<span>{t('quadrantMatrix.legend.importance', { ns: 'app' })}</span>
<span>{t('quadrantMatrix.legend.urgency', { ns: 'app' })}</span>
</div>
</div>
{/* Expanded Grid */}
<div className="min-h-0 flex-1">
{renderGrid(true)}
</div>
</div>
</FullScreenModal>
</>
)
}
export default QuadrantMatrix

View File

@@ -0,0 +1,102 @@
'use client'
import type { FC } from 'react'
import type { QuadrantConfig, Task } from './types'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import TaskItem from './task-item'
type QuadrantCardProps = {
config: QuadrantConfig
tasks: Task[]
expanded?: boolean
maxDisplay?: number
}
const QuadrantCard: FC<QuadrantCardProps> = ({
config,
tasks,
expanded = false,
maxDisplay = 3,
}) => {
const { t } = useTranslation()
const { number, titleKey, subtitleKey, bgClass, borderClass, titleClass } = config
const displayLimit = expanded ? Infinity : maxDisplay
const displayTasks = tasks.slice(0, displayLimit)
const remainingCount = Math.max(0, tasks.length - displayLimit)
return (
<div
className={cn(
'flex min-w-0 flex-col rounded-xl border p-3',
bgClass,
borderClass,
expanded ? 'min-h-[280px]' : 'min-h-[200px]',
)}
>
{/* Header with numbered circle */}
<div className="mb-2 shrink-0">
<div className="flex items-center gap-2">
{/* Numbered circle */}
<span className={cn(
'flex h-5 w-5 items-center justify-center rounded-full border text-xs font-semibold',
borderClass,
titleClass,
)}
>
{number}
</span>
<span className={cn('system-sm-semibold', titleClass)}>{t(titleKey, { ns: 'app' })}</span>
{tasks.length > 0 && (
<span className="bg-components-badge-bg-gray rounded-full px-1.5 py-0.5 text-[10px] font-medium text-text-tertiary">
{tasks.length}
</span>
)}
</div>
<div className="text-[11px] text-text-tertiary">{t(subtitleKey, { ns: 'app' })}</div>
</div>
{/* Task List */}
<div className={cn(
'flex min-h-0 flex-1 flex-col gap-2',
expanded && 'overflow-y-auto',
)}
>
{displayTasks.length > 0
? (
displayTasks.map((task) => {
const taskKey = [
task.name,
task.deadline ?? 'no-deadline',
task.importance_score,
task.urgency_score,
task.description ?? '',
task.action_advice ?? '',
].join('|')
return (
<TaskItem
key={taskKey}
task={task}
expanded={expanded}
/>
)
})
)
: (
<div className="flex flex-1 items-center justify-center text-xs text-text-quaternary">
{t('quadrantMatrix.noTasks', { ns: 'app' })}
</div>
)}
</div>
{/* More indicator (only in non-expanded mode) */}
{!expanded && remainingCount > 0 && (
<div className="mt-2 shrink-0 text-center text-[11px] text-text-tertiary">
{t('quadrantMatrix.more', { ns: 'app', count: remainingCount })}
</div>
)}
</div>
)
}
export default QuadrantCard

View File

@@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import type { Task } from './types'
import { RiCalendarLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type TaskItemProps = {
task: Task
expanded?: boolean
showScores?: boolean
}
const TaskItem: FC<TaskItemProps> = ({ task, expanded = false, showScores = true }) => {
const { t } = useTranslation()
const { name, description, deadline, importance_score, urgency_score, action_advice } = task
return (
<div className="group min-w-0 rounded-lg bg-components-panel-bg p-2.5 shadow-xs transition-all hover:shadow-sm">
{/* Header: Task Name + Scores */}
<div className="flex items-start justify-between gap-2">
<div
className={cn(
'system-sm-medium min-w-0 flex-1 text-text-primary',
!expanded && 'truncate',
)}
title={name}
>
{name}
</div>
{showScores && (
<div className="flex shrink-0 items-center gap-1 text-[10px] font-medium">
<span className="text-text-accent">
I:
{importance_score}
</span>
<span className="text-text-warning">
U:
{urgency_score}
</span>
</div>
)}
</div>
{/* Description */}
{description && (
<div className={cn(
'mt-1 text-xs text-text-tertiary',
!expanded && 'line-clamp-2',
)}
>
{description}
</div>
)}
{/* Deadline Badge */}
{deadline && (
<div className="mt-1.5">
<span className="bg-components-badge-bg-gray inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-text-tertiary">
<RiCalendarLine className="h-3 w-3" />
<span>
{t('quadrantMatrix.deadline', { ns: 'app' })}
{' '}
{deadline}
</span>
</span>
</div>
)}
{/* Action Advice */}
{action_advice && (
<div className="mt-2 border-t border-divider-subtle pt-2">
<p
className={cn(
'text-xs italic text-text-quaternary',
!expanded && 'line-clamp-2',
)}
title={!expanded ? action_advice : undefined}
>
{action_advice}
</p>
</div>
)}
</div>
)
}
export default TaskItem

View File

@@ -0,0 +1,92 @@
/**
* Type definitions for Eisenhower Matrix (Task Quadrant) visualization
*/
import type { I18nKeysWithPrefix } from '@/types/i18n'
export type Task = {
name: string
description?: string
deadline?: string // YYYY-MM-DD format
importance_score: number // 0-100, based on goal alignment and long-term value
urgency_score: number // 0-100, based on deadline pressure and delay penalty
action_advice?: string // Suggested action for this task
}
export type QuadrantData = {
q1: Task[] // Urgent & Important - Do First
q2: Task[] // Not Urgent & Important - Schedule
q3: Task[] // Urgent & Not Important - Delegate
q4: Task[] // Not Urgent & Not Important - Don't Do
}
type QuadrantKeyBase = I18nKeysWithPrefix<'app', 'quadrantMatrix.q'>
type QuadrantTitleKey = Extract<QuadrantKeyBase, `${string}.title`>
type QuadrantSubtitleKey = Extract<QuadrantKeyBase, `${string}.subtitle`>
export type QuadrantConfig = {
key: 'q1' | 'q2' | 'q3' | 'q4'
number: number
titleKey: QuadrantTitleKey // i18n key for title
subtitleKey: QuadrantSubtitleKey // i18n key for subtitle
bgClass: string
borderClass: string
titleClass: string
}
// Layout based on Eisenhower Matrix:
// Q1 (Do First) - top-left, Q2 (Schedule) - top-right
// Q3 (Delegate) - bottom-left, Q4 (Don't Do) - bottom-right
export const QUADRANT_CONFIGS: Record<string, QuadrantConfig> = {
q1: {
key: 'q1',
number: 1,
titleKey: 'quadrantMatrix.q1.title',
subtitleKey: 'quadrantMatrix.q1.subtitle',
bgClass: 'bg-state-destructive-hover',
borderClass: 'border-state-destructive-border',
titleClass: 'text-text-destructive',
},
q2: {
key: 'q2',
number: 2,
titleKey: 'quadrantMatrix.q2.title',
subtitleKey: 'quadrantMatrix.q2.subtitle',
bgClass: 'bg-state-accent-hover',
borderClass: 'border-state-accent-border',
titleClass: 'text-text-accent',
},
q3: {
key: 'q3',
number: 3,
titleKey: 'quadrantMatrix.q3.title',
subtitleKey: 'quadrantMatrix.q3.subtitle',
bgClass: 'bg-state-warning-hover',
borderClass: 'border-state-warning-border',
titleClass: 'text-text-warning',
},
q4: {
key: 'q4',
number: 4,
titleKey: 'quadrantMatrix.q4.title',
subtitleKey: 'quadrantMatrix.q4.subtitle',
bgClass: 'bg-components-panel-on-panel-item-bg',
borderClass: 'border-divider-regular',
titleClass: 'text-text-tertiary',
},
}
/**
* Validates if the data structure matches QuadrantData interface
*/
export function isValidQuadrantData(data: unknown): data is QuadrantData {
if (typeof data !== 'object' || data === null)
return false
const d = data as Record<string, unknown>
return (
Array.isArray(d.q1)
&& Array.isArray(d.q2)
&& Array.isArray(d.q3)
&& Array.isArray(d.q4)
)
}

View File

@@ -196,6 +196,24 @@
"publishApp.notSet": "Not set",
"publishApp.notSetDesc": "Currently nobody can access the web app. Please set permissions.",
"publishApp.title": "Who can access web app",
"quadrantMatrix.deadline": "DDL:",
"quadrantMatrix.invalidData": "Invalid Quadrant Data",
"quadrantMatrix.invalidDataDesc": "Expected JSON format with q1, q2, q3, q4 arrays",
"quadrantMatrix.legend.importance": "I = Importance",
"quadrantMatrix.legend.urgency": "U = Urgency",
"quadrantMatrix.more": "+{{count}} more",
"quadrantMatrix.noTasks": "No tasks",
"quadrantMatrix.q1.subtitle": "Urgent & Important",
"quadrantMatrix.q1.title": "Do First",
"quadrantMatrix.q2.subtitle": "Important & Not Urgent",
"quadrantMatrix.q2.title": "Schedule",
"quadrantMatrix.q3.subtitle": "Urgent & Not Important",
"quadrantMatrix.q3.title": "Delegate",
"quadrantMatrix.q4.subtitle": "Not Urgent & Not Important",
"quadrantMatrix.q4.title": "Don't Do",
"quadrantMatrix.taskCount_one": "{{count}} task prioritized",
"quadrantMatrix.taskCount_other": "{{count}} tasks prioritized",
"quadrantMatrix.title": "Eisenhower Matrix",
"removeOriginal": "Delete the original app",
"roadmap": "See our roadmap",
"showMyCreatedAppsOnly": "Created by me",

View File

@@ -196,6 +196,24 @@
"publishApp.notSet": "未设置",
"publishApp.notSetDesc": "当前任何人都无法访问 Web 应用。请设置访问权限。",
"publishApp.title": "谁可以访问 web 应用",
"quadrantMatrix.deadline": "截止:",
"quadrantMatrix.invalidData": "无效的象限数据",
"quadrantMatrix.invalidDataDesc": "需要包含 q1, q2, q3, q4 数组的 JSON 格式",
"quadrantMatrix.legend.importance": "I = 重要性",
"quadrantMatrix.legend.urgency": "U = 紧急性",
"quadrantMatrix.more": "+{{count}} 更多",
"quadrantMatrix.noTasks": "暂无任务",
"quadrantMatrix.q1.subtitle": "紧急且重要",
"quadrantMatrix.q1.title": "立即执行",
"quadrantMatrix.q2.subtitle": "重要但不紧急",
"quadrantMatrix.q2.title": "计划安排",
"quadrantMatrix.q3.subtitle": "紧急但不重要",
"quadrantMatrix.q3.title": "委派他人",
"quadrantMatrix.q4.subtitle": "不紧急也不重要",
"quadrantMatrix.q4.title": "不要做",
"quadrantMatrix.taskCount_one": "{{count}} 个任务已排序",
"quadrantMatrix.taskCount_other": "{{count}} 个任务已排序",
"quadrantMatrix.title": "艾森豪威尔矩阵",
"removeOriginal": "删除原应用",
"roadmap": "产品路线图",
"showMyCreatedAppsOnly": "我创建的",