diff --git a/README.md b/README.md index 75af2bf..7483ee4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ Artemis is a project and task management app built with React, TypeScript, and S ## Project Status -This project is currently in a maintenance pause phase. +Current status: V1 stable is ready for daily use. + +This repository is entering a two-track phase: +- `main`: stable maintenance branch for day-to-day usage +- next branch (planned): V2 architecture exploration for AI-first workflow ## Features @@ -28,4 +32,15 @@ This project is currently in a maintenance pause phase. - Live Demo: https://artemis-b7j.pages.dev/ - GitHub: https://github.com/macintoshwan/Artemis +## Next Step Preview (V2) + +Planned direction for the next iteration: + +- Unify task scheduling, journal-style records, and project context under a cleaner data model +- Keep V1 workflow available while V2 is developed in an isolated branch and Supabase project +- Add AI assistant capabilities for proactive progress check-ins and schedule planning +- Introduce LLM + RAG memory pipeline for context-aware task conversations + +V2 will be developed incrementally and will not break current V1 daily usage. + License: see repository LICENSE. diff --git a/src/components/App.tsx b/src/components/App.tsx index a7766d9..b4315ce 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -21,7 +21,8 @@ import { ProjectDetail } from './ProjectDetail'; import { ProjectEditModal } from './ProjectEditModal'; import { TaskEditModal } from './TaskEditModal'; import { TodoList } from './TodoList'; -import type { CheckinTemplate, TodoItem, Project, TaskStatus } from '../types'; +import { ScheduleTimeline } from './ScheduleTimeline'; +import type { CheckinTemplate, TodoItem, Project, TaskStatus, Task } from '../types'; import { useProjectsStore } from '../store/useProjectsStore'; import { deriveTaskStatus } from '../types'; import { useTaskActions } from '../hooks/useActions'; @@ -41,7 +42,7 @@ export default function App() { // 从 store 获取最基础的数据 const { optimisticInsertProject } = useProjectsStore(); - const { setTaskStatus } = useTaskActions(); + const { createTask, setTaskStatus } = useTaskActions(); const projectIds = useProjectsStore((s) => s.projectIds); const projects = useProjectsStore((s) => s.projects); const tasks = useProjectsStore((s) => s.tasks); @@ -67,6 +68,7 @@ export default function App() { for (const taskId of taskIdsArr) { const task = tasks[taskId]; if (!task) continue; + if (task.is_fixed_event) continue; const status = deriveTaskStatus(task); if (status !== 'done') { items.push({ @@ -110,8 +112,12 @@ export default function App() { // 新建项目浮层 const [isNewProjectOpen, setIsNewProjectOpen] = useState(false); const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false); + const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); const [isTodoProjectPickerOpen, setIsTodoProjectPickerOpen] = useState(false); const [todoCreateTarget, setTodoCreateTarget] = useState(null); + const [activeTodoTaskId, setActiveTodoTaskId] = useState(null); + const [scheduleViewDays, setScheduleViewDays] = useState(1); + const [scheduleModalDate, setScheduleModalDate] = useState(null); const [checkins, setCheckins] = useState([]); const [checkinRecords, setCheckinRecords] = useState>({}); const [checkinLoading, setCheckinLoading] = useState(false); @@ -210,6 +216,14 @@ export default function App() { setTodoCreateTarget(null); }, []); + const handleSelectTodoTask = useCallback((taskId: number) => { + setActiveTodoTaskId(taskId); + }, []); + + const handleCloseTodoTaskDetail = useCallback(() => { + setActiveTodoTaskId(null); + }, []); + const resolveTempTodoProjectId = useCallback(async (): Promise => { if (!user?.id) { throw new Error('请先登录'); @@ -249,6 +263,128 @@ export default function App() { setIsCheckinModalOpen(false); }, []); + const handleOpenSchedule = useCallback((dateKey?: string) => { + setScheduleModalDate(dateKey ?? todayKey); + setIsScheduleModalOpen(true); + }, [todayKey]); + + const handleCloseSchedule = useCallback(() => { + setIsScheduleModalOpen(false); + }, []); + + const scheduleEvents = useMemo(() => { + // 根据 scheduleViewDays 计算日期范围 + const dates: string[] = []; + for (let i = 0; i < scheduleViewDays; i++) { + const d = new Date(todayKey); + d.setDate(d.getDate() + i); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + dates.push(`${y}-${m}-${dd}`); + } + + const rangeStart = new Date(`${dates[0]}T00:00:00`); + const rangeEnd = new Date(`${dates[dates.length - 1]}T23:59:59.999`); + + return Object.values(tasks) + .filter((task): task is Task => Boolean(task && task.is_fixed_event && task.schedule_start_at && task.schedule_end_at)) + .map((task) => { + const rawStart = new Date(task.schedule_start_at!); + const rawEnd = new Date(task.schedule_end_at!); + if (isNaN(rawStart.getTime()) || isNaN(rawEnd.getTime())) return null; + if (rawEnd <= rangeStart || rawStart >= rangeEnd) return null; + + // 计算该任务属于哪一天及在该天的位置 + const taskDayKey = rawStart.toISOString().slice(0, 10); + const dayStartForTask = new Date(`${taskDayKey}T00:00:00`); + const dayEndForTask = new Date(`${taskDayKey}T23:59:59.999`); + + const clippedStart = rawStart < dayStartForTask ? dayStartForTask : rawStart; + const clippedEnd = rawEnd > dayEndForTask ? dayEndForTask : rawEnd; + const startMinute = Math.max(0, Math.floor((clippedStart.getTime() - dayStartForTask.getTime()) / 60_000)); + const endMinute = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStartForTask.getTime()) / 60_000)); + if (endMinute <= startMinute) return null; + + return { + id: task.id, + name: task.name, + startMinute, + endMinute, + dateKey: taskDayKey, + dayIndex: dates.indexOf(taskDayKey), + }; + }) + .filter((event): event is { id: number; name: string; startMinute: number; endMinute: number; dateKey: string; dayIndex: number } => event !== null && event.dayIndex >= 0) + .sort((a, b) => a.dayIndex !== b.dayIndex ? a.dayIndex - b.dayIndex : a.startMinute - b.startMinute); + }, [tasks, todayKey, scheduleViewDays]); + + const handleCreateScheduleTask = useCallback(async (input: { name: string; startTime: string; endTime: string; scheduleDate: string }) => { + if (!user?.id) { + alert('请先登录'); + return; + } + + const name = input.name.trim(); + if (!name) { + alert('请输入事项名称'); + return; + } + + const startMinute = parseTimeToMinute(input.startTime); + const endMinute = parseTimeToMinute(input.endTime); + if (startMinute === null || endMinute === null) { + alert('请输入正确的时间'); + return; + } + if (endMinute <= startMinute) { + alert('结束时间必须晚于开始时间'); + return; + } + + const overlapped = scheduleEvents.some((event) => event.dateKey === input.scheduleDate && !(endMinute <= event.startMinute || startMinute >= event.endMinute)); + if (overlapped) { + alert('该时段与已有日程重叠,请调整后重试'); + return; + } + + const startIso = combineDateAndTimeToIso(input.scheduleDate, input.startTime); + const endIso = combineDateAndTimeToIso(input.scheduleDate, input.endTime); + if (!startIso || !endIso) { + alert('时间格式无效'); + return; + } + + try { + const scheduleProjectId = await resolveTempTodoProjectId(); + await createTask({ + id: Date.now(), + project_id: scheduleProjectId, + name, + plan_start_date: null, + plan_end_date: null, + plan_duration: null, + actual_start_date: null, + actual_end_date: null, + actual_duration: null, + category: '日程', + bounty: null, + completed: false, + prerequisites: null, + priority: null, + user_id: user.id, + description: null, + is_fixed_event: true, + schedule_start_at: startIso, + schedule_end_at: endIso, + }); + setIsScheduleModalOpen(false); + } catch (error) { + console.error('创建日程占用失败:', error); + alert('创建日程占用失败,请重试'); + } + }, [user?.id, todayKey, scheduleEvents, resolveTempTodoProjectId, createTask]); + const todoProjectCandidates = projectIds .map((id) => projects[id]) .filter((project): project is Project => project !== undefined); @@ -352,6 +488,15 @@ export default function App() { items={todoItems} onCreateTodoTask={handleStartCreateTodoTask} onChangeTodoStatus={handleChangeTodoStatus} + onSelectTodoTask={handleSelectTodoTask} + /> + +
@@ -378,6 +523,14 @@ export default function App() { /> )} + {isScheduleModalOpen && ( + + )} + {isTodoProjectPickerOpen && ( )} + + {activeTodoTaskId !== null && ( + + )} )} @@ -453,6 +613,136 @@ function CheckinSection({ templates, records, todayKey, onCheckin, onCreateCheck ); } +interface ScheduleSectionProps { + events: Array<{ id: number; name: string; startMinute: number; endMinute: number; dateKey: string; dayIndex: number }>; + onCreateSchedule: (dateKey?: string) => void; + viewDays: number; + onViewDaysChange: (days: number) => void; + todayKey: string; +} + +function ScheduleSection({ events, onCreateSchedule, viewDays, onViewDaysChange, todayKey }: ScheduleSectionProps) { + return ( +
+
+

今日日程

+
+ {[1, 3, 5, 7].map((days) => ( + + ))} +
+
+ +
+ +
+
+ ); +} + +interface ScheduleCreateModalProps { + onClose: () => void; + onConfirm: (input: { name: string; startTime: string; endTime: string; scheduleDate: string }) => void; + defaultDate?: string; +} + +function ScheduleCreateModal({ onClose, onConfirm, defaultDate }: ScheduleCreateModalProps) { + const [name, setName] = useState(''); + const [scheduleDate, setScheduleDate] = useState(defaultDate ?? new Date().toISOString().slice(0, 10)); + const [startTime, setStartTime] = useState('08:00'); + const [endTime, setEndTime] = useState('09:00'); + + const handleConfirm = () => { + onConfirm({ + name, + startTime, + endTime, + scheduleDate, + }); + }; + + return ( +
+
e.stopPropagation()}> +
+ +
+
+

添加占用时间

+
+ + setName(e.target.value)} + placeholder="例如:高数课" + /> +
+
+ + setScheduleDate(e.target.value)} + /> +
+
+
+ + setStartTime(e.target.value)} + /> +
+
+ + setEndTime(e.target.value)} + /> +
+
+
+
+ +
+
+
+ ); +} + +interface TaskDetailModalProps { + taskId: number; + onClose: () => void; +} + +function TaskDetailModal({ taskId, onClose }: TaskDetailModalProps) { + const task = useProjectsStore((state) => state.tasks[taskId]); + const projectId = task?.project_id ?? -1; + + if (!task) return null; + + return ( + + ); +} + function getLocalDateKey() { const now = new Date(); const y = now.getFullYear(); @@ -461,6 +751,28 @@ function getLocalDateKey() { return `${y}-${m}-${d}`; } +function parseTimeToMinute(input: string): number | null { + const match = /^(\d{2}):(\d{2})$/.exec(input); + if (!match) return null; + const h = Number(match[1]); + const m = Number(match[2]); + if (!Number.isInteger(h) || !Number.isInteger(m)) return null; + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return h * 60 + m; +} + +function combineDateAndTimeToIso(dateKey: string, time: string): string | null { + const minute = parseTimeToMinute(time); + if (minute === null) return null; + const hour = Math.floor(minute / 60); + const min = minute % 60; + const hh = String(hour).padStart(2, '0'); + const mm = String(min).padStart(2, '0'); + const local = new Date(`${dateKey}T${hh}:${mm}:00`); + if (isNaN(local.getTime())) return null; + return local.toISOString(); +} + interface TodoProjectPickerModalProps { projects: Project[]; onClose: () => void; @@ -473,7 +785,7 @@ function TodoProjectPickerModal({ projects, onClose, onPick, onPickTemp }: TodoP
e.stopPropagation()}>
- +

选择待办所属项目

diff --git a/src/components/ScheduleTimeline.tsx b/src/components/ScheduleTimeline.tsx new file mode 100644 index 0000000..089001f --- /dev/null +++ b/src/components/ScheduleTimeline.tsx @@ -0,0 +1,148 @@ +import { memo, useMemo } from 'react'; + +interface ScheduleTimelineProps { + events: Array<{ id: number; name: string; startMinute: number; endMinute: number; dateKey: string; dayIndex: number }>; + todayKey: string; + viewDays: number; +} + +type Segment = + | { kind: 'free'; startMinute: number; endMinute: number } + | { kind: 'occupied'; startMinute: number; endMinute: number; id: number; name: string }; + +function minuteToLabel(minute: number): string { + const clamped = Math.max(0, Math.min(1440, minute)); + const h = Math.floor(clamped / 60); + const m = clamped % 60; + const hh = String(h).padStart(2, '0'); + const mm = String(m).padStart(2, '0'); + return `${hh}:${mm}`; +} + +function buildSegmentsForDay(events: Array<{ startMinute: number; endMinute: number; id: number; name: string }>): Segment[] { + const normalized = [...events] + .map((event) => ({ + ...event, + startMinute: Math.max(0, Math.min(1440, event.startMinute)), + endMinute: Math.max(0, Math.min(1440, event.endMinute)), + })) + .filter((event) => event.endMinute > event.startMinute) + .sort((a, b) => a.startMinute - b.startMinute); + + const segments: Segment[] = []; + let cursor = 0; + + for (const event of normalized) { + if (event.startMinute > cursor) { + segments.push({ + kind: 'free', + startMinute: cursor, + endMinute: event.startMinute, + }); + } + + segments.push({ + kind: 'occupied', + startMinute: event.startMinute, + endMinute: event.endMinute, + id: event.id, + name: event.name, + }); + + cursor = Math.max(cursor, event.endMinute); + } + + if (cursor < 1440) { + segments.push({ + kind: 'free', + startMinute: cursor, + endMinute: 1440, + }); + } + + if (segments.length === 0) { + segments.push({ + kind: 'free', + startMinute: 0, + endMinute: 1440, + }); + } + + return segments; +} + +export const ScheduleTimeline = memo(function ScheduleTimeline({ events, todayKey, viewDays }: ScheduleTimelineProps) { + // 生成日期列表 + const dates = useMemo(() => { + const result: string[] = []; + for (let i = 0; i < viewDays; i++) { + const d = new Date(todayKey); + d.setDate(d.getDate() + i); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + result.push(`${y}-${m}-${dd}`); + } + return result; + }, [todayKey, viewDays]); + + // 按日期分组事件 + const eventsByDate = useMemo(() => { + const grouped: Record = {}; + for (const date of dates) { + grouped[date] = events.filter((e) => e.dateKey === date); + } + return grouped; + }, [events, dates]); + + // 获取日期显示文本 + function formatDateLabel(dateKey: string): string { + const d = new Date(`${dateKey}T00:00:00`); + const weekDay = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]; + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${m}/${dd} 周${weekDay}`; + } + + return ( +
+ {dates.map((dateKey) => ( +
+
{formatDateLabel(dateKey)}
+
+ {buildSegmentsForDay(eventsByDate[dateKey] || []).map((segment, index) => { + const duration = segment.endMinute - segment.startMinute; + const flexGrow = Math.max(1, duration); + const start = minuteToLabel(segment.startMinute); + const end = minuteToLabel(segment.endMinute); + + if (segment.kind === 'occupied') { + return ( +
+
{start} - {end}
+
{segment.name}
+
+ ); + } + + return ( +
+
{start} - {end}
+
可用
+
+ ); + })} +
+
+ ))} +
+ ); +}); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 6fb2677..ac01d41 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -12,12 +12,14 @@ interface TodoListProps { items: TodoItem[]; onCreateTodoTask: () => void; onChangeTodoStatus: (taskId: number, status: TaskStatus) => Promise; + onSelectTodoTask: (taskId: number) => void; } export function TodoList({ items, onCreateTodoTask, onChangeTodoStatus, + onSelectTodoTask, }: TodoListProps) { const handleChangeStatus = useCallback( async (taskId: number, status: TaskStatus) => { @@ -39,7 +41,19 @@ export function TodoList({ ) : (
    {items.map((item) => ( -
  • +
  • onSelectTodoTask(item.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectTodoTask(item.id); + } + }} + >
    {STATUS_OPTIONS.map(({ key, label }) => (
    - - {item.name} - - {item.projectName && ( - {item.projectName} - )} +
    + + {item.name} + + {item.projectName && ( + {item.projectName} + )} +
  • ))}
diff --git a/src/components/TodoListContainer.tsx b/src/components/TodoListContainer.tsx index 0fee93e..362b89b 100644 --- a/src/components/TodoListContainer.tsx +++ b/src/components/TodoListContainer.tsx @@ -8,11 +8,13 @@ import type { TaskStatus } from '../types'; interface TodoListContainerProps { onCreateTodoTask: () => void; onChangeTodoStatus: (taskId: number, status: TaskStatus) => Promise; + onSelectTodoTask: (taskId: number) => void; } export function TodoListContainer({ onCreateTodoTask, onChangeTodoStatus, + onSelectTodoTask, }: TodoListContainerProps) { const projectIds = useProjectsStore(selectProjectIds); const projects = useProjectsStore((state) => state.projects); @@ -52,6 +54,7 @@ export function TodoListContainer({ items={todoItems} onCreateTodoTask={onCreateTodoTask} onChangeTodoStatus={onChangeTodoStatus} + onSelectTodoTask={onSelectTodoTask} /> ); } diff --git a/src/styles/base.css b/src/styles/base.css index f62fe32..8c77008 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -651,6 +651,36 @@ input[type="text"] { color: var(--text-primary); } +input[type="time"] { + width: 100%; + padding: 12px var(--spacing-md); + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-base); + font-size: var(--font-size-base); + outline: none; + background: var(--bg-page); + color: var(--text-primary); +} + +input[type="time"]:focus { + border-color: var(--color-cyan); +} + +input[type="date"] { + width: 100%; + padding: 12px var(--spacing-md); + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-base); + font-size: var(--font-size-base); + outline: none; + background: var(--bg-page); + color: var(--text-primary); +} + +input[type="date"]:focus { + border-color: var(--color-cyan); +} + /* 隐藏 number 输入框的上下调整按钮 */ input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { @@ -1035,10 +1065,19 @@ button:hover { cursor: not-allowed; } -.checkin-modal { +.modal-container.checkin-modal { max-width: 520px; } +.modal-container.schedule-modal { + max-width: 980px; +} + +.schedule-modal .modal-title { + font-size: var(--font-size-xl); + margin-bottom: 16px; +} + .checkin-modal-header { padding-bottom: 0; } @@ -1053,6 +1092,114 @@ button:hover { padding: 0 var(--spacing-xl) var(--spacing-xl); } +/* ========== 日程模块 ========== */ +.schedule-section { + margin-bottom: var(--spacing-xl); + padding: var(--spacing-lg); + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-large); + background: var(--bg-card); +} + +.schedule-view-btn { + padding: 6px 12px; + font-size: 12px; + background: transparent; + color: var(--text-color); + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-base); + cursor: pointer; + transition: all var(--transition-fast); +} + +.schedule-view-btn.active { + background: var(--color-cyan); + color: var(--color-on-primary); + border-color: var(--color-cyan); +} + +.schedule-view-btn:hover { + opacity: 0.85; +} + +.schedule-timeline-container { + display: flex; + gap: var(--spacing-md); + margin-top: var(--spacing-md); + overflow-x: auto; + align-items: stretch; + padding-bottom: 4px; +} + +.schedule-day-column { + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-base); + overflow: hidden; + min-width: 220px; + flex: 1 0 220px; +} + +.schedule-day-header { + padding: 8px 12px; + background: rgba(102, 255, 204, 0.1); + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + border-bottom: var(--border-width-module) solid var(--border-module); +} + +.schedule-timeline { + display: flex; + flex-direction: column; + overflow: hidden; + height: 720px; +} + +.schedule-block { + padding: 8px 10px; + border-bottom: var(--border-width-task) solid var(--border-module); + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + overflow: hidden; +} + +.schedule-block:last-child { + border-bottom: none; +} + +.schedule-block-free { + background: rgba(102, 255, 204, 0.2); +} + +.schedule-block-occupied { + background: rgba(255, 68, 68, 0.2); +} + +.schedule-block-time { + font-size: 11px; + line-height: 1; + opacity: 0.9; +} + +.schedule-block-name { + font-size: 18px; + color: var(--text-primary); + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.05; + word-break: break-word; + font-weight: 600; +} + +.schedule-time-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md); +} + /* ========== 次要按钮 - 透明底+黄色描边 ========== */ .btn-secondary { background: transparent; @@ -1111,7 +1258,7 @@ button:hover { text-align: center; color: var(--text-primary); margin-bottom: 25px; - font-size: var(--font-size-xxl); + font-size: var(--font-size-xl); display: inline-block; width: 100%; } @@ -2059,6 +2206,7 @@ input[readonly] { background: var(--bg-task); border: var(--border-width-task) solid var(--border-module); border-radius: var(--radius-base); + cursor: pointer; } .todo-checkbox { @@ -2074,6 +2222,15 @@ input[readonly] { font-size: var(--font-size-base); } +.todo-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; +} + .todo-text.completed { text-decoration: line-through; opacity: 0.6; diff --git a/src/types/index.ts b/src/types/index.ts index ad0fad2..18476bf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,6 +41,9 @@ export interface Task { priority: TaskPriority | null; // jsonb → structured user_id: string; description: string | null; + is_fixed_event?: boolean; + schedule_start_at?: string | null; + schedule_end_at?: string | null; } /** priority 字段 JSONB 结构 */ diff --git a/supabase/migrations/005_add_task_schedule_columns.sql b/supabase/migrations/005_add_task_schedule_columns.sql new file mode 100644 index 0000000..dd5473e --- /dev/null +++ b/supabase/migrations/005_add_task_schedule_columns.sql @@ -0,0 +1,10 @@ +-- Add fixed schedule event fields to tasks +ALTER TABLE public.tasks + ADD COLUMN IF NOT EXISTS is_fixed_event boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS schedule_start_at timestamp with time zone, + ADD COLUMN IF NOT EXISTS schedule_end_at timestamp with time zone; + +-- Fast filter for daily schedule lookup +CREATE INDEX IF NOT EXISTS idx_tasks_fixed_schedule + ON public.tasks (user_id, schedule_start_at) + WHERE is_fixed_event = true;