import { BarChart3, ChevronLeft, ChevronRight, Dumbbell, Smile } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useKnowledgeHabits } from '@/hooks/use-knowledge'
import { cn } from '@/lib/utils'
// ─── Types ────────────────────────────────────────────────────
/** Backend: habit name → { dayOfYear → status } */
type HabitMap = Record<string, Record<string, number>>
// ─── Helpers ──────────────────────────────────────────────────
/** Get number of days in a year */
function daysInYear(year: number): number {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 366 : 365
}
/** Get day-of-year for a date */
function dayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0)
const diff = date.getTime() - start.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
/** Get the ISO week number for a day-of-year */
function weekOfYear(year: number, doy: number): number {
const jan1 = new Date(year, 0, 1)
const dayNum = jan1.getDay() // 0=Sun
// Adjust: Monday = start of week
const offset = dayNum === 0 ? 6 : dayNum - 1
return Math.floor((doy - 1 + offset) / 7)
}
/** Total weeks in the grid (53 covers all years) */
const TOTAL_WEEKS = 53
/** Colored dot representing a habit/mood status. */
function StatusDot({ status, isMood }: { status: number; isMood: boolean }) {
let color = 'bg-muted/40'
if (status !== -1 && status !== undefined) {
if (isMood) {
const moodColors = [
'bg-muted',
'bg-error',
'bg-warning',
'bg-warning/80',
'bg-success/60',
'bg-success',
]
const level = Math.min(Math.max(status, 0), 5)
color = moodColors[level] ?? color
} else if (status > 0) {
color = 'bg-success/80'
}
}
return <span className={cn('inline-block h-2 w-2 rounded-full align-middle', color)} />
}
// ─── Year Grid (GitHub contribution graph style) ──────────────
function HabitYearGrid({
habitName,
yearData,
year,
isMood,
}: {
habitName: string
yearData: Record<string, number>
year: number
isMood: boolean
}) {
const { t } = useTranslation()
const [hoveredDay, setHoveredDay] = useState<number | null>(null)
// Build a 53×7 grid (week × weekday)
// Each cell represents one day. Position by (week, weekday)
const totalDays = daysInYear(year)
// Build grid: map each day to (week, weekday)
const grid = useMemo(() => {
const cells: Array<{
doy: number
week: number
weekday: number
status: number
date: Date
}> = []
for (let doy = 1; doy <= totalDays; doy++) {
const date = new Date(year, 0, doy)
const weekday = date.getDay() // 0=Sun
const week = weekOfYear(year, doy)
const status = yearData[String(doy)] ?? -1 // -1 = no data
cells.push({ doy, week, weekday, status, date })
}
return cells
}, [yearData, year, totalDays])
// Calculate stats
const completedDays = useMemo(
() => Object.values(yearData).filter((v) => v > 0).length,
[yearData],
)
const totalTracked = Object.keys(yearData).length
// Color mapping
const getColor = (status: number, isMood: boolean): string => {
if (status === -1) return 'bg-transparent'
if (status === 0) return 'bg-muted/40'
if (isMood) {
// Mood: 1-5 scale
const level = Math.min(Math.max(status, 0), 5)
const colors = [
'bg-muted/40',
'bg-error/60',
'bg-warning/60',
'bg-warning/50',
'bg-success/70',
'bg-success',
]
return colors[level] ?? 'bg-muted/40'
}
// Regular habit: completed
// Check if weekend (simplified: we use the status from backend)
return 'bg-success/80'
}
// Format tooltip
const formatTooltip = (doy: number, status: number): string => {
const date = new Date(year, 0, doy)
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
if (status === -1) return `${dateStr}`
if (status === 0) return `${dateStr}: ${t('knowledge.markIncomplete')}`
if (isMood) {
return `${dateStr}: ●`
}
return `${dateStr}: ${t('knowledge.markComplete')}`
}
// Build grid as CSS grid: 53 columns (weeks) × 7 rows (Mon-Sun)
// Re-map weekday: Mon=0, Tue=1, ..., Sun=6
const remapWeekday = (dow: number) => (dow === 0 ? 6 : dow - 1)
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-1.5">
{isMood && <Smile className="h-4 w-4" />}
{habitName}
</h3>
<span className="text-xs text-muted-foreground">
{completedDays}/{totalTracked}
</span>
</div>
{/* Year grid */}
<div
className="relative"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${TOTAL_WEEKS}, 1fr)`,
gridTemplateRows: 'repeat(7, 1fr)',
gap: '2px',
width: '100%',
aspectRatio: `${TOTAL_WEEKS * 1.2} / 7`, // slightly wider cells
}}
>
{grid.map((cell) => {
const col = cell.week + 1
const row = remapWeekday(cell.weekday) + 1
return (
<div
key={cell.doy}
className={cn(
'rounded-sm transition-colors cursor-default',
getColor(cell.status, isMood),
hoveredDay === cell.doy && 'ring-1 ring-foreground/30',
)}
style={{ gridColumn: col, gridRow: row }}
onMouseEnter={() => setHoveredDay(cell.doy)}
onMouseLeave={() => setHoveredDay(null)}
title={formatTooltip(cell.doy, cell.status)}
/>
)
})}
</div>
{/* Tooltip */}
{hoveredDay !== null &&
(() => {
const date = new Date(year, 0, hoveredDay)
const status = yearData[String(hoveredDay)]
const dateStr = date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
weekday: 'short',
})
return (
<div className="text-xs text-muted-foreground">
{dateStr}:{' '}
{status === undefined ? '—' : <StatusDot status={status} isMood={isMood ?? false} />}
</div>
)
})()}
{/* Month labels */}
<div
className="text-2xs text-muted-foreground/60 mt-1"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${TOTAL_WEEKS}, 1fr)`,
gap: '2px',
}}
>
{Array.from({ length: 12 }, (_, m) => {
const firstDay = new Date(year, m, 1)
const doy = dayOfYear(firstDay)
const week = weekOfYear(year, doy)
return (
<span key={m} style={{ gridColumn: `${week + 1} / span 3` }}>
{firstDay.toLocaleDateString(undefined, { month: 'short' })}
</span>
)
})}
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────
export function Habits() {
const { t } = useTranslation()
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const { data: habits, isLoading } = useKnowledgeHabits(year)
if (isLoading) {
return <div className="p-6 text-muted-foreground">{t('knowledge.loadingHabits')}</div>
}
const habitsData = habits as HabitMap | undefined
const habitEntries = habitsData ? Object.entries(habitsData) : []
// Sort: Mood last
const sortedEntries = habitEntries.sort(([a], [b]) => {
if (a === 'Mood') return 1
if (b === 'Mood') return -1
return a.localeCompare(b)
})
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Dumbbell className="h-5 w-5" />
{t('knowledge.habits')}
</h2>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setYear((y) => y - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium w-12 text-center">{year}</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setYear((y) => Math.min(y + 1, currentYear))}
disabled={year >= currentYear}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-muted/40 inline-block" />
{t('knowledge.habitSkipped')}
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-success/80 inline-block" />
{t('knowledge.habitCompleted')}
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-success inline-block" />
{t('knowledge.habitWeekend')}
</span>
</div>
{/* Habit grids */}
{sortedEntries.length > 0 ? (
<div className="space-y-8">
{sortedEntries.map(([habitName, yearData]) => (
<HabitYearGrid
key={habitName}
habitName={habitName}
yearData={yearData}
year={year}
isMood={habitName === 'Mood'}
/>
))}
</div>
) : (
<div className="text-center py-12">
<BarChart3 className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">{t('knowledge.noHabitData', { year })}</p>
<p className="text-xs text-muted-foreground mt-1">{t('knowledge.trackHabitsHint')}</p>
</div>
)}
</div>
)
}