create-arete-workspace 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/bin/arete.js +156 -0
- package/bin/create.js +111 -0
- package/lib/install-openclaw.js +50 -0
- package/lib/scaffold.js +213 -0
- package/lib/setup-wizard.js +88 -0
- package/lib/updater.js +130 -0
- package/package.json +34 -0
- package/packages/gatsaeng-os/README.md +36 -0
- package/packages/gatsaeng-os/components.json +23 -0
- package/packages/gatsaeng-os/eslint.config.mjs +18 -0
- package/packages/gatsaeng-os/next.config.ts +7 -0
- package/packages/gatsaeng-os/package.json +59 -0
- package/packages/gatsaeng-os/postcss.config.mjs +7 -0
- package/packages/gatsaeng-os/public/file.svg +1 -0
- package/packages/gatsaeng-os/public/globe.svg +1 -0
- package/packages/gatsaeng-os/public/next.svg +1 -0
- package/packages/gatsaeng-os/public/vercel.svg +1 -0
- package/packages/gatsaeng-os/public/window.svg +1 -0
- package/packages/gatsaeng-os/python/api_server.py +248 -0
- package/packages/gatsaeng-os/python/briefing.py +145 -0
- package/packages/gatsaeng-os/python/config.py +55 -0
- package/packages/gatsaeng-os/python/goal_context_agent.py +193 -0
- package/packages/gatsaeng-os/python/gyeokguk.py +171 -0
- package/packages/gatsaeng-os/python/proactive.py +158 -0
- package/packages/gatsaeng-os/python/requirements.txt +11 -0
- package/packages/gatsaeng-os/python/run.py +28 -0
- package/packages/gatsaeng-os/python/scoring.py +44 -0
- package/packages/gatsaeng-os/python/streak.py +70 -0
- package/packages/gatsaeng-os/python/telegram_bot.py +331 -0
- package/packages/gatsaeng-os/python/timing_engine.py +117 -0
- package/packages/gatsaeng-os/python/vault_io.py +423 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/areas/[id]/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/areas/page.tsx +161 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/books/[id]/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/books/page.tsx +268 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/calendar/page.tsx +379 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/error.tsx +30 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/focus/page.tsx +293 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/goals/[id]/page.tsx +426 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/goals/page.tsx +178 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/layout.tsx +29 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/notes/[id]/page.tsx +147 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/notes/page.tsx +254 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/page.tsx +26 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/projects/[id]/page.tsx +86 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/projects/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/review/page.tsx +475 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/routines/page.tsx +436 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/tasks/[id]/page.tsx +210 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/tasks/page.tsx +307 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/voice/page.tsx +212 -0
- package/packages/gatsaeng-os/src/app/api/areas/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/areas/route.ts +22 -0
- package/packages/gatsaeng-os/src/app/api/auth/login/route.ts +52 -0
- package/packages/gatsaeng-os/src/app/api/auth/logout/route.ts +8 -0
- package/packages/gatsaeng-os/src/app/api/books/[id]/route.ts +27 -0
- package/packages/gatsaeng-os/src/app/api/books/route.ts +20 -0
- package/packages/gatsaeng-os/src/app/api/calendar/[id]/route.ts +24 -0
- package/packages/gatsaeng-os/src/app/api/calendar/import/route.ts +52 -0
- package/packages/gatsaeng-os/src/app/api/calendar/route.ts +37 -0
- package/packages/gatsaeng-os/src/app/api/daily/route.ts +51 -0
- package/packages/gatsaeng-os/src/app/api/goals/[id]/route.ts +34 -0
- package/packages/gatsaeng-os/src/app/api/goals/route.ts +30 -0
- package/packages/gatsaeng-os/src/app/api/logs/energy/route.ts +40 -0
- package/packages/gatsaeng-os/src/app/api/logs/focus/route.ts +22 -0
- package/packages/gatsaeng-os/src/app/api/logs/routine/route.ts +54 -0
- package/packages/gatsaeng-os/src/app/api/milestones/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/milestones/route.ts +47 -0
- package/packages/gatsaeng-os/src/app/api/notes/[id]/route.ts +29 -0
- package/packages/gatsaeng-os/src/app/api/notes/route.ts +37 -0
- package/packages/gatsaeng-os/src/app/api/profile/route.ts +17 -0
- package/packages/gatsaeng-os/src/app/api/projects/[id]/route.ts +27 -0
- package/packages/gatsaeng-os/src/app/api/projects/route.ts +25 -0
- package/packages/gatsaeng-os/src/app/api/reviews/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/reviews/route.ts +29 -0
- package/packages/gatsaeng-os/src/app/api/routines/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/routines/route.ts +28 -0
- package/packages/gatsaeng-os/src/app/api/tasks/[id]/route.ts +28 -0
- package/packages/gatsaeng-os/src/app/api/tasks/route.ts +66 -0
- package/packages/gatsaeng-os/src/app/api/timing/current/route.ts +63 -0
- package/packages/gatsaeng-os/src/app/api/voice/chat/route.ts +50 -0
- package/packages/gatsaeng-os/src/app/api/voice/transcribe/route.ts +25 -0
- package/packages/gatsaeng-os/src/app/api/voice/tts/route.ts +36 -0
- package/packages/gatsaeng-os/src/app/error.tsx +30 -0
- package/packages/gatsaeng-os/src/app/favicon.ico +0 -0
- package/packages/gatsaeng-os/src/app/globals.css +208 -0
- package/packages/gatsaeng-os/src/app/layout.tsx +33 -0
- package/packages/gatsaeng-os/src/app/login/page.tsx +87 -0
- package/packages/gatsaeng-os/src/app/providers.tsx +27 -0
- package/packages/gatsaeng-os/src/components/ErrorBoundary.tsx +46 -0
- package/packages/gatsaeng-os/src/components/dashboard/DashboardGrid.tsx +86 -0
- package/packages/gatsaeng-os/src/components/dashboard/DdayWidget.tsx +88 -0
- package/packages/gatsaeng-os/src/components/dashboard/EnergyTracker.tsx +87 -0
- package/packages/gatsaeng-os/src/components/dashboard/FocusTimer.tsx +139 -0
- package/packages/gatsaeng-os/src/components/dashboard/GatsaengScore.tsx +30 -0
- package/packages/gatsaeng-os/src/components/dashboard/GoalRings.tsx +107 -0
- package/packages/gatsaeng-os/src/components/dashboard/ProactiveBar.tsx +98 -0
- package/packages/gatsaeng-os/src/components/dashboard/RoutineChecklist.tsx +81 -0
- package/packages/gatsaeng-os/src/components/dashboard/TimingWidget.tsx +86 -0
- package/packages/gatsaeng-os/src/components/dashboard/WidgetCustomizer.tsx +95 -0
- package/packages/gatsaeng-os/src/components/dashboard/WidgetWrapper.tsx +33 -0
- package/packages/gatsaeng-os/src/components/dashboard/ZeigarnikPanel.tsx +43 -0
- package/packages/gatsaeng-os/src/components/editor/EditorToolbar.tsx +186 -0
- package/packages/gatsaeng-os/src/components/editor/TiptapEditor.tsx +114 -0
- package/packages/gatsaeng-os/src/components/layout/Header.tsx +47 -0
- package/packages/gatsaeng-os/src/components/layout/MobileBottomNav.tsx +122 -0
- package/packages/gatsaeng-os/src/components/layout/MobileSidebar.tsx +29 -0
- package/packages/gatsaeng-os/src/components/layout/Sidebar.tsx +142 -0
- package/packages/gatsaeng-os/src/components/onboarding/OnboardingFlow.tsx +229 -0
- package/packages/gatsaeng-os/src/components/onboarding/OnboardingGate.tsx +78 -0
- package/packages/gatsaeng-os/src/components/projects/CalendarView.tsx +152 -0
- package/packages/gatsaeng-os/src/components/projects/KanbanView.tsx +180 -0
- package/packages/gatsaeng-os/src/components/projects/ListView.tsx +82 -0
- package/packages/gatsaeng-os/src/components/projects/TableView.tsx +206 -0
- package/packages/gatsaeng-os/src/components/projects/TaskCard.tsx +154 -0
- package/packages/gatsaeng-os/src/components/projects/TaskForm.tsx +128 -0
- package/packages/gatsaeng-os/src/components/projects/ViewSwitcher.tsx +40 -0
- package/packages/gatsaeng-os/src/components/search/GlobalSearch.tsx +179 -0
- package/packages/gatsaeng-os/src/components/shared/InlineEdit.tsx +77 -0
- package/packages/gatsaeng-os/src/components/shared/PinButton.tsx +42 -0
- package/packages/gatsaeng-os/src/components/tasks/DDayBadge.tsx +34 -0
- package/packages/gatsaeng-os/src/components/ui/badge.tsx +48 -0
- package/packages/gatsaeng-os/src/components/ui/button.tsx +64 -0
- package/packages/gatsaeng-os/src/components/ui/card.tsx +92 -0
- package/packages/gatsaeng-os/src/components/ui/checkbox.tsx +32 -0
- package/packages/gatsaeng-os/src/components/ui/command.tsx +184 -0
- package/packages/gatsaeng-os/src/components/ui/dialog.tsx +158 -0
- package/packages/gatsaeng-os/src/components/ui/input.tsx +21 -0
- package/packages/gatsaeng-os/src/components/ui/label.tsx +24 -0
- package/packages/gatsaeng-os/src/components/ui/popover.tsx +89 -0
- package/packages/gatsaeng-os/src/components/ui/progress.tsx +31 -0
- package/packages/gatsaeng-os/src/components/ui/select.tsx +190 -0
- package/packages/gatsaeng-os/src/components/ui/sheet.tsx +143 -0
- package/packages/gatsaeng-os/src/components/ui/tabs.tsx +91 -0
- package/packages/gatsaeng-os/src/components/ui/toggle-group.tsx +83 -0
- package/packages/gatsaeng-os/src/components/ui/toggle.tsx +47 -0
- package/packages/gatsaeng-os/src/components/ui/tooltip.tsx +57 -0
- package/packages/gatsaeng-os/src/hooks/useAreas.ts +53 -0
- package/packages/gatsaeng-os/src/hooks/useBooks.ts +62 -0
- package/packages/gatsaeng-os/src/hooks/useCalendar.ts +59 -0
- package/packages/gatsaeng-os/src/hooks/useDaily.ts +15 -0
- package/packages/gatsaeng-os/src/hooks/useGlobalTasks.ts +45 -0
- package/packages/gatsaeng-os/src/hooks/useGoals.ts +53 -0
- package/packages/gatsaeng-os/src/hooks/useMilestones.ts +75 -0
- package/packages/gatsaeng-os/src/hooks/useNotes.ts +65 -0
- package/packages/gatsaeng-os/src/hooks/useProjects.ts +102 -0
- package/packages/gatsaeng-os/src/hooks/useRoutines.ts +76 -0
- package/packages/gatsaeng-os/src/hooks/useTiming.ts +27 -0
- package/packages/gatsaeng-os/src/lib/apiFetch.ts +14 -0
- package/packages/gatsaeng-os/src/lib/auth.ts +32 -0
- package/packages/gatsaeng-os/src/lib/date.ts +7 -0
- package/packages/gatsaeng-os/src/lib/editor/markdown.ts +35 -0
- package/packages/gatsaeng-os/src/lib/llm-governor.ts +167 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/energyCycle.ts +35 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/habitStack.ts +22 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/scoring.ts +32 -0
- package/packages/gatsaeng-os/src/lib/routes.ts +15 -0
- package/packages/gatsaeng-os/src/lib/utils.ts +6 -0
- package/packages/gatsaeng-os/src/lib/vault/config.ts +29 -0
- package/packages/gatsaeng-os/src/lib/vault/frontmatter.ts +84 -0
- package/packages/gatsaeng-os/src/lib/vault/index.ts +180 -0
- package/packages/gatsaeng-os/src/lib/vault/schemas.ts +274 -0
- package/packages/gatsaeng-os/src/middleware.ts +34 -0
- package/packages/gatsaeng-os/src/stores/dashboardStore.ts +26 -0
- package/packages/gatsaeng-os/src/stores/favoritesStore.ts +47 -0
- package/packages/gatsaeng-os/src/stores/timerStore.ts +65 -0
- package/packages/gatsaeng-os/src/types/index.ts +320 -0
- package/packages/gatsaeng-os/tsconfig.json +34 -0
- package/templates/scripts/forge_qa.sh.tmpl +237 -0
- package/templates/scripts/forge_ship.sh.tmpl +183 -0
- package/templates/scripts/session_indexer.py.tmpl +420 -0
- package/templates/scripts/tracer.py.tmpl +266 -0
- package/templates/workspace/AGENTS.md.tmpl +190 -0
- package/templates/workspace/BOOTSTRAP.md.tmpl +27 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +23 -0
- package/templates/workspace/MEMORY.md.tmpl +35 -0
- package/templates/workspace/SOUL.md.tmpl +258 -0
- package/templates/workspace/TOOLS.md.tmpl +28 -0
- package/templates/workspace/USER.md.tmpl +43 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode } from 'react'
|
|
4
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { AlertTriangle } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
children: ReactNode
|
|
10
|
+
fallback?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface State {
|
|
14
|
+
hasError: boolean
|
|
15
|
+
error?: Error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
19
|
+
state: State = { hasError: false }
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: Error): State {
|
|
22
|
+
return { hasError: true, error }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.hasError) {
|
|
27
|
+
return this.props.fallback ?? (
|
|
28
|
+
<Card className="border-gatsaeng-red/30">
|
|
29
|
+
<CardContent className="py-8 text-center">
|
|
30
|
+
<AlertTriangle className="w-8 h-8 text-gatsaeng-red mx-auto mb-3" />
|
|
31
|
+
<p className="text-sm text-foreground mb-1">오류가 발생했습니다</p>
|
|
32
|
+
<p className="text-xs text-muted-foreground mb-4">{this.state.error?.message}</p>
|
|
33
|
+
<Button
|
|
34
|
+
variant="outline"
|
|
35
|
+
size="sm"
|
|
36
|
+
onClick={() => this.setState({ hasError: false, error: undefined })}
|
|
37
|
+
>
|
|
38
|
+
다시 시도
|
|
39
|
+
</Button>
|
|
40
|
+
</CardContent>
|
|
41
|
+
</Card>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return this.props.children
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { useDashboardStore } from '@/stores/dashboardStore'
|
|
5
|
+
import { RoutineChecklist } from './RoutineChecklist'
|
|
6
|
+
import { GoalRings } from './GoalRings'
|
|
7
|
+
import { FocusTimer } from './FocusTimer'
|
|
8
|
+
import { GatsaengScore } from './GatsaengScore'
|
|
9
|
+
import { ZeigarnikPanel } from './ZeigarnikPanel'
|
|
10
|
+
import { EnergyTracker } from './EnergyTracker'
|
|
11
|
+
import { DdayWidget } from './DdayWidget'
|
|
12
|
+
import { ProactiveBar } from './ProactiveBar'
|
|
13
|
+
import { TimingWidget } from './TimingWidget'
|
|
14
|
+
import type { WidgetId } from '@/types'
|
|
15
|
+
|
|
16
|
+
const WIDGETS: Partial<Record<WidgetId, React.ComponentType>> = {
|
|
17
|
+
routine: RoutineChecklist,
|
|
18
|
+
goals: GoalRings,
|
|
19
|
+
timer: FocusTimer,
|
|
20
|
+
zeigarnik: () => null, // rendered separately as top bar
|
|
21
|
+
energy: EnergyTracker,
|
|
22
|
+
dday: DdayWidget,
|
|
23
|
+
proactive: () => null, // rendered as top bar
|
|
24
|
+
heatmap: () => (
|
|
25
|
+
<div className="border border-border rounded-lg p-6 text-center text-muted-foreground text-sm">
|
|
26
|
+
Activity Heatmap — coming soon
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
kanban: () => (
|
|
30
|
+
<div className="border border-border rounded-lg p-6 text-center text-muted-foreground text-sm">
|
|
31
|
+
Quick Kanban — coming soon
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DashboardGrid() {
|
|
37
|
+
const { activeWidgets } = useDashboardStore()
|
|
38
|
+
const { data: profile } = useQuery({
|
|
39
|
+
queryKey: ['profile'],
|
|
40
|
+
queryFn: async () => {
|
|
41
|
+
const res = await fetch('/api/profile')
|
|
42
|
+
return res.json()
|
|
43
|
+
},
|
|
44
|
+
staleTime: 1000 * 60 * 30,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const displayWidgets = activeWidgets.filter(w => w !== 'zeigarnik' && w !== 'proactive')
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-6">
|
|
51
|
+
{/* Proactive alerts bar */}
|
|
52
|
+
{activeWidgets.includes('proactive') && <ProactiveBar />}
|
|
53
|
+
|
|
54
|
+
{/* Zeigarnik panel */}
|
|
55
|
+
{activeWidgets.includes('zeigarnik') && <ZeigarnikPanel />}
|
|
56
|
+
|
|
57
|
+
{/* Saju motto banner */}
|
|
58
|
+
{profile?.saju_motto && (
|
|
59
|
+
<div className="border border-gatsaeng-purple/30 bg-gatsaeng-purple/5 rounded-lg px-4 py-3">
|
|
60
|
+
<div className="text-[10px] uppercase tracking-wider text-gatsaeng-purple/60 mb-1">
|
|
61
|
+
사주 모토
|
|
62
|
+
</div>
|
|
63
|
+
<p className="text-sm text-foreground font-medium">{profile.saju_motto}</p>
|
|
64
|
+
{profile.saju_identity && (
|
|
65
|
+
<p className="text-xs text-muted-foreground mt-1">{profile.saju_identity}</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{/* Score + Timing row */}
|
|
71
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
72
|
+
<GatsaengScore />
|
|
73
|
+
<TimingWidget />
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Widget grid */}
|
|
77
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
78
|
+
{displayWidgets.map(widgetId => {
|
|
79
|
+
const Widget = WIDGETS[widgetId]
|
|
80
|
+
if (!Widget) return null
|
|
81
|
+
return <Widget key={widgetId} />
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMilestonesWithDDay } from '@/hooks/useMilestones'
|
|
4
|
+
import { useGoals } from '@/hooks/useGoals'
|
|
5
|
+
import { WidgetWrapper } from './WidgetWrapper'
|
|
6
|
+
import { Calendar, Target } from 'lucide-react'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import Link from 'next/link'
|
|
9
|
+
|
|
10
|
+
function formatDDay(d: number) {
|
|
11
|
+
if (d === 0) return 'D-Day'
|
|
12
|
+
if (d > 0) return `D-${d}`
|
|
13
|
+
return `D+${Math.abs(d)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ddayColor(d: number) {
|
|
17
|
+
if (d <= 0) return 'text-gatsaeng-red'
|
|
18
|
+
if (d <= 7) return 'text-gatsaeng-red'
|
|
19
|
+
if (d <= 30) return 'text-gatsaeng-amber'
|
|
20
|
+
return 'text-foreground'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function DdayWidget() {
|
|
24
|
+
const { data: milestones = [], isLoading } = useMilestonesWithDDay()
|
|
25
|
+
const { data: goals = [] } = useGoals()
|
|
26
|
+
|
|
27
|
+
const active = milestones.filter(m => m.status === 'active').slice(0, 5)
|
|
28
|
+
|
|
29
|
+
const goalMap = new Map(goals.map(g => [g.id, g]))
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<WidgetWrapper title="D-day 트래커" icon={<Calendar className="w-4 h-4 text-gatsaeng-red" />}>
|
|
33
|
+
{isLoading ? (
|
|
34
|
+
<div className="space-y-3">
|
|
35
|
+
{[1, 2, 3].map(i => <div key={i} className="h-10 bg-muted/30 rounded animate-pulse" />)}
|
|
36
|
+
</div>
|
|
37
|
+
) : active.length === 0 ? (
|
|
38
|
+
<p className="text-sm text-muted-foreground">활성 마일스톤이 없습니다.</p>
|
|
39
|
+
) : (
|
|
40
|
+
<div className="space-y-3">
|
|
41
|
+
{active.map(m => {
|
|
42
|
+
const goal = goalMap.get(m.goal_id)
|
|
43
|
+
const progress = m.target_value > 0
|
|
44
|
+
? Math.min(100, Math.round((m.current_value / m.target_value) * 100))
|
|
45
|
+
: 0
|
|
46
|
+
return (
|
|
47
|
+
<Link
|
|
48
|
+
key={m.id}
|
|
49
|
+
href={`/goals/${m.goal_id}`}
|
|
50
|
+
className="flex items-center gap-3 group"
|
|
51
|
+
>
|
|
52
|
+
<div className={`text-lg font-bold font-mono min-w-[60px] text-right ${ddayColor(m.d_day)}`}>
|
|
53
|
+
{formatDDay(m.d_day)}
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex-1 min-w-0">
|
|
56
|
+
<div className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">
|
|
57
|
+
{m.title}
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
60
|
+
{goal && (
|
|
61
|
+
<div className="flex items-center gap-1">
|
|
62
|
+
<Target className="w-3 h-3" style={{ color: goal.color }} />
|
|
63
|
+
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{goal.title}</span>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
|
67
|
+
{progress}%
|
|
68
|
+
</Badge>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
{/* mini progress bar */}
|
|
72
|
+
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
73
|
+
<div
|
|
74
|
+
className="h-full rounded-full transition-all"
|
|
75
|
+
style={{
|
|
76
|
+
width: `${progress}%`,
|
|
77
|
+
backgroundColor: goal?.color ?? '#58a6ff',
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
</Link>
|
|
82
|
+
)
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</WidgetWrapper>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { WidgetWrapper } from './WidgetWrapper'
|
|
5
|
+
import { Zap } from 'lucide-react'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
import { apiFetch } from '@/lib/apiFetch'
|
|
8
|
+
import { getToday } from '@/lib/date'
|
|
9
|
+
import type { EnergyLog } from '@/types'
|
|
10
|
+
|
|
11
|
+
const ENERGY_LEVELS = [
|
|
12
|
+
{ value: 1, label: '매우 낮음', color: 'bg-gatsaeng-red' },
|
|
13
|
+
{ value: 2, label: '낮음', color: 'bg-orange-500' },
|
|
14
|
+
{ value: 3, label: '보통', color: 'bg-yellow-500' },
|
|
15
|
+
{ value: 4, label: '높음', color: 'bg-gatsaeng-teal' },
|
|
16
|
+
{ value: 5, label: '최고', color: 'bg-green-500' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
export function EnergyTracker() {
|
|
20
|
+
const today = getToday()
|
|
21
|
+
const currentHour = new Date().getHours()
|
|
22
|
+
const queryClient = useQueryClient()
|
|
23
|
+
|
|
24
|
+
const { data: energyLog } = useQuery({
|
|
25
|
+
queryKey: ['energy-log', today],
|
|
26
|
+
queryFn: () => apiFetch<EnergyLog>(`/api/logs/energy?date=${today}`),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const logMutation = useMutation({
|
|
30
|
+
mutationFn: (level: number) =>
|
|
31
|
+
apiFetch('/api/logs/energy', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ date: today, hour: currentHour, level }),
|
|
35
|
+
}),
|
|
36
|
+
onSuccess: () => {
|
|
37
|
+
queryClient.invalidateQueries({ queryKey: ['energy-log', today] })
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const currentEntry = energyLog?.entries?.find(e => e.hour === currentHour)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<WidgetWrapper title="에너지 체크" icon={<Zap className="w-4 h-4 text-gatsaeng-amber" />} widgetId="energy">
|
|
45
|
+
<div className="space-y-3">
|
|
46
|
+
<p className="text-xs text-muted-foreground">
|
|
47
|
+
현재 시간: {currentHour}시 — 지금 에너지 레벨은?
|
|
48
|
+
</p>
|
|
49
|
+
<div className="flex gap-2">
|
|
50
|
+
{ENERGY_LEVELS.map(level => (
|
|
51
|
+
<button
|
|
52
|
+
key={level.value}
|
|
53
|
+
onClick={() => logMutation.mutate(level.value)}
|
|
54
|
+
className={cn(
|
|
55
|
+
'w-8 h-8 rounded-full transition-all text-xs font-bold',
|
|
56
|
+
currentEntry?.level === level.value
|
|
57
|
+
? `${level.color} text-white scale-110`
|
|
58
|
+
: 'bg-muted text-muted-foreground hover:scale-105'
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{level.value}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
{energyLog?.entries && energyLog.entries.length > 0 && (
|
|
66
|
+
<div className="flex gap-1 mt-2">
|
|
67
|
+
{Array.from({ length: 24 }, (_, h) => {
|
|
68
|
+
const entry = energyLog.entries.find(e => e.hour === h)
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
key={h}
|
|
72
|
+
className={cn(
|
|
73
|
+
'w-2 h-6 rounded-sm',
|
|
74
|
+
entry
|
|
75
|
+
? ENERGY_LEVELS[entry.level - 1]?.color ?? 'bg-muted'
|
|
76
|
+
: 'bg-muted/30'
|
|
77
|
+
)}
|
|
78
|
+
title={`${h}시: ${entry ? `레벨 ${entry.level}` : '미기록'}`}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
})}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</WidgetWrapper>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react'
|
|
4
|
+
import { WidgetWrapper } from './WidgetWrapper'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Badge } from '@/components/ui/badge'
|
|
7
|
+
import { Input } from '@/components/ui/input'
|
|
8
|
+
import { Timer, Play, Pause, RotateCcw } from 'lucide-react'
|
|
9
|
+
import { useTimerStore } from '@/stores/timerStore'
|
|
10
|
+
import { cn } from '@/lib/utils'
|
|
11
|
+
import type { SessionType } from '@/types'
|
|
12
|
+
|
|
13
|
+
const SESSION_LABELS: Record<SessionType, string> = {
|
|
14
|
+
pomodoro_25: '25분',
|
|
15
|
+
focus_90: '90분 딥워크',
|
|
16
|
+
deep_work: '커스텀',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function FocusTimer() {
|
|
20
|
+
const seconds = useTimerStore(s => s.seconds)
|
|
21
|
+
const isRunning = useTimerStore(s => s.isRunning)
|
|
22
|
+
const sessionType = useTimerStore(s => s.sessionType)
|
|
23
|
+
const completedSessions = useTimerStore(s => s.completedSessions)
|
|
24
|
+
const customMinutes = useTimerStore(s => s.customMinutes)
|
|
25
|
+
const startedAt = useTimerStore(s => s.startedAt)
|
|
26
|
+
const start = useTimerStore(s => s.start)
|
|
27
|
+
const pause = useTimerStore(s => s.pause)
|
|
28
|
+
const reset = useTimerStore(s => s.reset)
|
|
29
|
+
const tick = useTimerStore(s => s.tick)
|
|
30
|
+
const setSessionType = useTimerStore(s => s.setSessionType)
|
|
31
|
+
const setCustomMinutes = useTimerStore(s => s.setCustomMinutes)
|
|
32
|
+
|
|
33
|
+
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
34
|
+
const prevCompletedRef = useRef(completedSessions)
|
|
35
|
+
|
|
36
|
+
const saveFocusSession = useCallback(async (started: string, type: SessionType, custom: number) => {
|
|
37
|
+
const durationMap: Record<SessionType, number> = {
|
|
38
|
+
pomodoro_25: 25,
|
|
39
|
+
focus_90: 90,
|
|
40
|
+
deep_work: custom,
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await fetch('/api/logs/focus', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
session_type: type,
|
|
48
|
+
duration_minutes: durationMap[type],
|
|
49
|
+
completed: true,
|
|
50
|
+
started_at: started,
|
|
51
|
+
ended_at: new Date().toISOString(),
|
|
52
|
+
}),
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
// silently fail — vault save is best-effort from dashboard widget
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (completedSessions > prevCompletedRef.current && startedAt) {
|
|
61
|
+
saveFocusSession(startedAt, sessionType, customMinutes)
|
|
62
|
+
}
|
|
63
|
+
prevCompletedRef.current = completedSessions
|
|
64
|
+
}, [completedSessions, startedAt, sessionType, customMinutes, saveFocusSession])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (isRunning) {
|
|
68
|
+
intervalRef.current = setInterval(tick, 1000)
|
|
69
|
+
} else if (intervalRef.current) {
|
|
70
|
+
clearInterval(intervalRef.current)
|
|
71
|
+
}
|
|
72
|
+
return () => {
|
|
73
|
+
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
74
|
+
}
|
|
75
|
+
}, [isRunning, tick])
|
|
76
|
+
|
|
77
|
+
const minutes = Math.floor(seconds / 60)
|
|
78
|
+
const secs = seconds % 60
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<WidgetWrapper title="포커스 타이머" icon={<Timer className="w-4 h-4" />} widgetId="timer">
|
|
82
|
+
<div className="flex flex-col items-center gap-4">
|
|
83
|
+
<div className="flex gap-2">
|
|
84
|
+
{(Object.keys(SESSION_LABELS) as SessionType[]).map(type => (
|
|
85
|
+
<Badge
|
|
86
|
+
key={type}
|
|
87
|
+
variant={sessionType === type ? 'default' : 'outline'}
|
|
88
|
+
className={cn(
|
|
89
|
+
'cursor-pointer text-xs',
|
|
90
|
+
sessionType === type && 'bg-gatsaeng-purple text-white'
|
|
91
|
+
)}
|
|
92
|
+
onClick={() => setSessionType(type)}
|
|
93
|
+
>
|
|
94
|
+
{SESSION_LABELS[type]}
|
|
95
|
+
</Badge>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{sessionType === 'deep_work' && !isRunning && seconds === customMinutes * 60 && (
|
|
100
|
+
<div className="flex items-center gap-2">
|
|
101
|
+
<Input
|
|
102
|
+
type="number"
|
|
103
|
+
value={customMinutes}
|
|
104
|
+
onChange={e => setCustomMinutes(Number(e.target.value))}
|
|
105
|
+
className="w-20 h-8 text-center text-sm"
|
|
106
|
+
min={1}
|
|
107
|
+
max={180}
|
|
108
|
+
/>
|
|
109
|
+
<span className="text-xs text-muted-foreground">분</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<div className="text-4xl font-mono font-bold text-gatsaeng-purple tabular-nums">
|
|
114
|
+
{String(minutes).padStart(2, '0')}:{String(secs).padStart(2, '0')}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex gap-2">
|
|
118
|
+
<Button
|
|
119
|
+
size="sm"
|
|
120
|
+
variant={isRunning ? 'outline' : 'default'}
|
|
121
|
+
onClick={isRunning ? pause : start}
|
|
122
|
+
className={cn(!isRunning && 'bg-gatsaeng-purple hover:bg-gatsaeng-purple/80')}
|
|
123
|
+
>
|
|
124
|
+
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
|
125
|
+
</Button>
|
|
126
|
+
<Button size="sm" variant="outline" onClick={reset}>
|
|
127
|
+
<RotateCcw className="w-4 h-4" />
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{completedSessions > 0 && (
|
|
132
|
+
<span className="text-xs text-muted-foreground">
|
|
133
|
+
완료 세션: {completedSessions}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</WidgetWrapper>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { WidgetWrapper } from './WidgetWrapper'
|
|
5
|
+
import { Zap } from 'lucide-react'
|
|
6
|
+
import type { Profile } from '@/types'
|
|
7
|
+
|
|
8
|
+
export function GatsaengScore() {
|
|
9
|
+
const { data: profile } = useQuery({
|
|
10
|
+
queryKey: ['profile'],
|
|
11
|
+
queryFn: async (): Promise<Profile> => {
|
|
12
|
+
const res = await fetch('/api/profile')
|
|
13
|
+
return res.json()
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<WidgetWrapper title="갓생 스코어" icon={<Zap className="w-4 h-4 text-gatsaeng-amber" />}>
|
|
19
|
+
<div className="flex items-center gap-4">
|
|
20
|
+
<div className="text-3xl font-bold font-mono text-gatsaeng-amber">
|
|
21
|
+
{profile?.total_score ?? 0}
|
|
22
|
+
</div>
|
|
23
|
+
<div className="text-sm text-muted-foreground">
|
|
24
|
+
<div>Lv. {profile?.level ?? 1}</div>
|
|
25
|
+
<div>연속 {profile?.current_streak ?? 0}일</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</WidgetWrapper>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { WidgetWrapper } from './WidgetWrapper'
|
|
4
|
+
import { Target } from 'lucide-react'
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
6
|
+
import { useGoals } from '@/hooks/useGoals'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_RING_COLOR = '#58a6ff'
|
|
9
|
+
const VALID_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^(rgb|hsl)/
|
|
10
|
+
|
|
11
|
+
function RingChart({ progress, color, size = 60 }: { progress: number; color: string; size?: number }) {
|
|
12
|
+
const radius = (size - 8) / 2
|
|
13
|
+
const circumference = 2 * Math.PI * radius
|
|
14
|
+
const offset = circumference - (progress / 100) * circumference
|
|
15
|
+
const safeColor = VALID_COLOR_RE.test(color) ? color : DEFAULT_RING_COLOR
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<svg width={size} height={size} className="transform -rotate-90">
|
|
19
|
+
<circle
|
|
20
|
+
cx={size / 2}
|
|
21
|
+
cy={size / 2}
|
|
22
|
+
r={radius}
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
strokeWidth={4}
|
|
26
|
+
className="text-muted/30"
|
|
27
|
+
/>
|
|
28
|
+
<circle
|
|
29
|
+
cx={size / 2}
|
|
30
|
+
cy={size / 2}
|
|
31
|
+
r={radius}
|
|
32
|
+
fill="none"
|
|
33
|
+
stroke={safeColor}
|
|
34
|
+
strokeWidth={4}
|
|
35
|
+
strokeDasharray={circumference}
|
|
36
|
+
strokeDashoffset={offset}
|
|
37
|
+
strokeLinecap="round"
|
|
38
|
+
className="transition-all duration-500"
|
|
39
|
+
/>
|
|
40
|
+
</svg>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function GoalRings() {
|
|
45
|
+
const { data: goals, isLoading } = useGoals()
|
|
46
|
+
|
|
47
|
+
const activeGoals = (goals ?? []).filter(g => g.status === 'active')
|
|
48
|
+
|
|
49
|
+
if (isLoading) {
|
|
50
|
+
return (
|
|
51
|
+
<WidgetWrapper title="목표 진척률" icon={<Target className="w-4 h-4" />}>
|
|
52
|
+
<div className="flex gap-4">
|
|
53
|
+
{[1, 2, 3].map(i => (
|
|
54
|
+
<div key={i} className="w-16 h-16 rounded-full bg-muted animate-pulse" />
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</WidgetWrapper>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<WidgetWrapper title="목표 진척률" icon={<Target className="w-4 h-4" />}>
|
|
63
|
+
{activeGoals.length === 0 ? (
|
|
64
|
+
<p className="text-sm text-muted-foreground">목표를 추가해보세요</p>
|
|
65
|
+
) : (
|
|
66
|
+
<div className="flex flex-wrap gap-4">
|
|
67
|
+
{activeGoals.map(goal => {
|
|
68
|
+
const progress = goal.target_value
|
|
69
|
+
? Math.round(((goal.current_value ?? 0) / goal.target_value) * 100)
|
|
70
|
+
: 0
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Tooltip key={goal.id}>
|
|
74
|
+
<TooltipTrigger asChild>
|
|
75
|
+
<div className="flex flex-col items-center gap-1 cursor-pointer">
|
|
76
|
+
<div className="relative">
|
|
77
|
+
<RingChart progress={progress} color={goal.color} />
|
|
78
|
+
<span className="absolute inset-0 flex items-center justify-center text-xs font-mono">
|
|
79
|
+
{progress}%
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
<span className="text-xs text-muted-foreground max-w-[70px] truncate">
|
|
83
|
+
{goal.title}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
</TooltipTrigger>
|
|
87
|
+
<TooltipContent side="bottom" className="max-w-xs">
|
|
88
|
+
<p className="font-medium">{goal.title}</p>
|
|
89
|
+
{goal.why_statement && (
|
|
90
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
91
|
+
Why: {goal.why_statement}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
{goal.target_value && (
|
|
95
|
+
<p className="text-xs mt-1">
|
|
96
|
+
{goal.current_value ?? 0} / {goal.target_value} {goal.unit}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
</TooltipContent>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
)
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</WidgetWrapper>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { useMilestonesWithDDay } from '@/hooks/useMilestones'
|
|
5
|
+
import { useRoutines } from '@/hooks/useRoutines'
|
|
6
|
+
import { AlertTriangle, Clock, Target, ChevronRight, X } from 'lucide-react'
|
|
7
|
+
import type { ProactiveAlert } from '@/types'
|
|
8
|
+
|
|
9
|
+
export function ProactiveBar() {
|
|
10
|
+
const { data: milestones = [] } = useMilestonesWithDDay()
|
|
11
|
+
const { routines } = useRoutines()
|
|
12
|
+
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
|
13
|
+
|
|
14
|
+
// build alerts from current data
|
|
15
|
+
const alerts: ProactiveAlert[] = []
|
|
16
|
+
const now = new Date()
|
|
17
|
+
const hour = now.getHours()
|
|
18
|
+
|
|
19
|
+
// 1. milestone D-day alerts (≤ 7 days)
|
|
20
|
+
milestones
|
|
21
|
+
.filter(m => m.status === 'active' && m.d_day <= 7 && m.d_day >= 0)
|
|
22
|
+
.forEach(m => {
|
|
23
|
+
alerts.push({
|
|
24
|
+
id: `ms-${m.id}`,
|
|
25
|
+
type: 'milestone_dday',
|
|
26
|
+
title: m.d_day === 0 ? 'D-Day!' : `D-${m.d_day}`,
|
|
27
|
+
message: m.title,
|
|
28
|
+
created_at: now.toISOString(),
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// 2. skipped routine alerts (only after 11am)
|
|
33
|
+
if (hour >= 11) {
|
|
34
|
+
const skipped = routines.filter(r => !r.completed_today)
|
|
35
|
+
if (skipped.length > 0) {
|
|
36
|
+
alerts.push({
|
|
37
|
+
id: 'skipped-routines',
|
|
38
|
+
type: 'skipped_routine',
|
|
39
|
+
title: `미완료 루틴 ${skipped.length}개`,
|
|
40
|
+
message: skipped.slice(0, 3).map(r => r.title).join(', '),
|
|
41
|
+
created_at: now.toISOString(),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. overdue milestones
|
|
47
|
+
milestones
|
|
48
|
+
.filter(m => m.status === 'active' && m.d_day < 0)
|
|
49
|
+
.slice(0, 2)
|
|
50
|
+
.forEach(m => {
|
|
51
|
+
alerts.push({
|
|
52
|
+
id: `overdue-${m.id}`,
|
|
53
|
+
type: 'deadline',
|
|
54
|
+
title: `D+${Math.abs(m.d_day)} 초과`,
|
|
55
|
+
message: m.title,
|
|
56
|
+
created_at: now.toISOString(),
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const visible = alerts.filter(a => !dismissed.has(a.id))
|
|
61
|
+
|
|
62
|
+
if (visible.length === 0) return null
|
|
63
|
+
|
|
64
|
+
const iconMap: Record<ProactiveAlert['type'], React.ReactNode> = {
|
|
65
|
+
skipped_routine: <Clock className="w-3.5 h-3.5" />,
|
|
66
|
+
deadline: <AlertTriangle className="w-3.5 h-3.5" />,
|
|
67
|
+
milestone_dday: <Target className="w-3.5 h-3.5" />,
|
|
68
|
+
reanalysis_due: <ChevronRight className="w-3.5 h-3.5" />,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const colorMap: Record<ProactiveAlert['type'], string> = {
|
|
72
|
+
skipped_routine: 'bg-gatsaeng-amber/10 border-gatsaeng-amber/30 text-gatsaeng-amber',
|
|
73
|
+
deadline: 'bg-gatsaeng-red/10 border-gatsaeng-red/30 text-gatsaeng-red',
|
|
74
|
+
milestone_dday: 'bg-gatsaeng-purple/10 border-gatsaeng-purple/30 text-gatsaeng-purple',
|
|
75
|
+
reanalysis_due: 'bg-primary/10 border-primary/30 text-primary',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-2 mb-4">
|
|
80
|
+
{visible.map(alert => (
|
|
81
|
+
<div
|
|
82
|
+
key={alert.id}
|
|
83
|
+
className={`flex items-center gap-3 px-4 py-2 rounded-lg border text-sm ${colorMap[alert.type]}`}
|
|
84
|
+
>
|
|
85
|
+
{iconMap[alert.type]}
|
|
86
|
+
<span className="font-medium">{alert.title}</span>
|
|
87
|
+
<span className="text-foreground/70">{alert.message}</span>
|
|
88
|
+
<button
|
|
89
|
+
onClick={() => setDismissed(prev => new Set(prev).add(alert.id))}
|
|
90
|
+
className="ml-auto opacity-50 hover:opacity-100 transition-opacity"
|
|
91
|
+
>
|
|
92
|
+
<X className="w-3.5 h-3.5" />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|