|
|
/* 아리 — 라이프 케어(Life Care)
|
|
|
건강·웰니스 / 금융·소비 / 지식·학습(제2의 뇌)
|
|
|
아리가 각 영역의 데이터를 읽고 분석·코칭·리마인더를 미리 준비해 둔다. */
|
|
|
const { useState, useEffect, useRef, useMemo } = React;
|
|
|
const L = window.LifeData;
|
|
|
|
|
|
/* ---------------- 아이콘 ---------------- */
|
|
|
const P = {
|
|
|
spark: "M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9z|M19 13l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7z",
|
|
|
grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z",
|
|
|
inbox: "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
|
|
|
route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11",
|
|
|
cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
|
|
|
check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
|
|
|
mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
|
|
|
brain: "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14",
|
|
|
heart: "M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7z",
|
|
|
wallet: "M19 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4|M18 12a1 1 0 0 0 0 2h3v-2z",
|
|
|
search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
|
|
|
bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0",
|
|
|
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
|
|
|
moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
|
|
|
plus: "M12 5v14M5 12h14",
|
|
|
x: "M18 6 6 18M6 6l12 12",
|
|
|
chev: "M9 6l6 6-6 6",
|
|
|
arrow: "M5 12h14|M13 6l6 6-6 6",
|
|
|
more: "M5 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM11 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM17 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0z",
|
|
|
refresh: "M21 12a9 9 0 1 1-3-6.7|M21 3v5h-5",
|
|
|
tick: "M20 6 9 17l-5-5",
|
|
|
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z|M12 7v5l3 2",
|
|
|
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
|
|
|
send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
|
|
|
steps: "M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0zM20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0z",
|
|
|
flame: "M8.5 14.5A2.5 2.5 0 0 0 11 17a2.5 2.5 0 0 0 2.5-2.5c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z",
|
|
|
water: "M12 2.7S6 9.5 6 14a6 6 0 0 0 12 0c0-4.5-6-11.3-6-11.3z",
|
|
|
stretch: "M22 12h-4l-3 9L9 3l-3 9H2",
|
|
|
pill: "M10.5 20.5 3.5 13.5a5 5 0 0 1 7-7l7 7a5 5 0 0 1-7 7z|M8.5 8.5l7 7",
|
|
|
feather: "M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z|M16 8 2 22|M17.5 15H9",
|
|
|
food: "M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2M5 2v20|M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3zm0 0v7",
|
|
|
bag: "M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z|M3 6h18|M16 10a4 4 0 0 1-8 0",
|
|
|
car: "M5 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0zM15 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0z|M5 17H3v-4l2-5h11l3 5v4h-2M5 13h14",
|
|
|
coffee: "M17 8h1a4 4 0 1 1 0 8h-1|M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4z|M6 2v2M10 2v2M14 2v2",
|
|
|
repeat: "M17 2l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3",
|
|
|
ticket: "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2z|M13 5v2M13 17v2M13 11v2",
|
|
|
play: "M6 3l15 9-15 9z",
|
|
|
music: "M9 18V5l12-2v13|M6 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
|
|
alert: "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h16.9a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z|M12 9v4M12 17h.01",
|
|
|
trending: "M22 7l-8.5 8.5-5-5L2 17|M16 7h6v6",
|
|
|
article: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|M14 2v6h6|M9 13h6M9 17h5",
|
|
|
note: "M12 20h9|M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z",
|
|
|
idea: "M9 18h6|M10 22h4|M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z",
|
|
|
highlight: "M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z",
|
|
|
folder: "M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
|
|
|
link: "M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 0 1 0 10h-2M8 12h8",
|
|
|
layers: "M12 2 2 7l10 5 10-5z|M2 17l10 5 10-5M2 12l10 5 10-5",
|
|
|
};
|
|
|
function I({ d, w }) {
|
|
|
return (
|
|
|
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
|
|
|
{P[d].split("|").map((s, i) => <path key={i} d={s} />)}
|
|
|
</svg>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const MAIN = [
|
|
|
{ id: "dash", label: "대시보드", icon: "grid", href: "대시보드.html" },
|
|
|
{ id: "sbox", label: "인박스", icon: "inbox", href: "인박스.html" },
|
|
|
{ id: "appr", label: "결재함", icon: "spark", badge: "3", href: "결재함.html" },
|
|
|
{ id: "auto", label: "자동화", icon: "zap", href: "자동화.html" },
|
|
|
{ id: "journey", label: "여정", icon: "route", href: "여정.html" },
|
|
|
{ id: "cal", label: "일정", icon: "cal", href: "일정.html" },
|
|
|
{ id: "task", label: "작업", icon: "check", badge: "4", href: "작업.html" },
|
|
|
{ id: "mail", label: "메일", icon: "mail", href: "메일.html" },
|
|
|
{ id: "noti", label: "알림", icon: "bell", badge: "6", href: "알림.html" },
|
|
|
{ id: "research", label: "리서치", icon: "brain", href: "리서치.html" },
|
|
|
{ id: "trip", label: "여행", icon: "send", href: "여행.html" },
|
|
|
{ id: "life", label: "라이프", icon: "heart", href: "라이프.html" },
|
|
|
{ id: "wrap", label: "하루 마감", icon: "moon", href: "하루 마감.html" },
|
|
|
];
|
|
|
|
|
|
const MODULES = [
|
|
|
{ id: "today", label: "오늘의 라이프", sub: "통합 코칭", icon: "spark", tone: "coral" },
|
|
|
{ id: "health", label: "건강 & 웰니스", sub: "활동·수면·습관", icon: "heart", tone: "coral" },
|
|
|
{ id: "finance", label: "금융 & 소비", sub: "예산·구독·자산", icon: "wallet", tone: "blue" },
|
|
|
{ id: "knowledge", label: "지식 & 학습", sub: "제2의 뇌", icon: "brain", tone: "violet" },
|
|
|
];
|
|
|
|
|
|
function load(k, fb) { try { const v = localStorage.getItem("ariL." + k); return v === null ? fb : JSON.parse(v); } catch (e) { return fb; } }
|
|
|
function save(k, v) { try { localStorage.setItem("ariL." + k, JSON.stringify(v)); } catch (e) {} }
|
|
|
|
|
|
const won = (n) => "₩" + n.toLocaleString("ko-KR");
|
|
|
|
|
|
/* ===================== 액티비티 링 ===================== */
|
|
|
function Ring({ r }) {
|
|
|
return (
|
|
|
<div className="ring-card">
|
|
|
<div className="ring" style={{ "--p": r.pct, "--c": `var(--${r.tone})` }}>
|
|
|
<div className="r-in">
|
|
|
<I d={r.icon} />
|
|
|
<b>{r.val}</b>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="rc-label">{r.label}{r.unit && <span className="t-muted"> {r.unit}</span>}</div>
|
|
|
<div className="rc-goal">목표 {r.goal}</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* ===================== 건강 모듈 ===================== */
|
|
|
function HealthModule({ habits, onHabit, toast }) {
|
|
|
const h = L.health;
|
|
|
return (
|
|
|
<>
|
|
|
<div className="mod-head">
|
|
|
<div className="mh-ic bg-coral"><I d="heart" /></div>
|
|
|
<div className="mh-txt">
|
|
|
<h2>건강 & 웰니스</h2>
|
|
|
<div className="mh-sub">Apple Health · Apple Watch 연동 · 오늘 {new Date().getHours()}시 기준</div>
|
|
|
</div>
|
|
|
<div className="mh-act">
|
|
|
<span className="c-act" onClick={() => toast("건강 리포트를 생성하고 있어요", "coral")}><I d="layers" />주간 리포트</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="card glassy" style={{ borderColor: "color-mix(in oklab, var(--coral) 22%, var(--line-2))" }}>
|
|
|
<div style={{ display: "flex", gap: 13, alignItems: "flex-start" }}>
|
|
|
<div className="ch-ava" style={{ width: 38, height: 38, borderRadius: 11, background: "var(--coral)", color: "#fff", display: "grid", placeItems: "center", flexShrink: 0, boxShadow: "0 6px 16px -6px var(--coral)" }}><I d="spark" /></div>
|
|
|
<div style={{ minWidth: 0 }}>
|
|
|
<div style={{ fontSize: 12, fontWeight: 700, color: "var(--coral)" }}>아리의 건강 코칭</div>
|
|
|
<p style={{ fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.6, margin: "6px 0 0" }} dangerouslySetInnerHTML={{ __html: h.coach }} />
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="lgrid">
|
|
|
<div className="col-7">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-coral"><I d="flame" /></div>
|
|
|
<div className="c-h"><b>오늘의 활동</b><span>목표까지 조금만 더</span></div>
|
|
|
</div>
|
|
|
<div className="rings3">{h.rings.map((r) => <Ring key={r.label} r={r} />)}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="col-5">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-blue"><I d="stretch" /></div>
|
|
|
<div className="c-h"><b>바이탈</b><span>웨어러블 측정값</span></div>
|
|
|
</div>
|
|
|
<div className="vitals">
|
|
|
{h.vitals.map((v) => (
|
|
|
<div className="vital-row" key={v.label}>
|
|
|
<div className={"v-dot bg-" + v.tone}><I d={v.trend === "up" ? "trending" : v.trend === "down" ? "heart" : "clock"} /></div>
|
|
|
<div className="v-main"><b>{v.label}</b><span>{v.note}</span></div>
|
|
|
<div className="v-val">{v.val}{v.unit && <small>{v.unit}</small>}</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="col-7">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-violet"><I d="moon" /></div>
|
|
|
<div className="c-h"><b>수면 분석</b><span>어젯밤 · 7일 추이</span></div>
|
|
|
</div>
|
|
|
<div className="sleep-top">
|
|
|
<div className="sleep-score" style={{ "--p": h.sleep.score }}>
|
|
|
<b>{h.sleep.score}</b><span>점수</span>
|
|
|
</div>
|
|
|
<div className="sleep-meta">
|
|
|
<div className="sm-total">{h.sleep.total}</div>
|
|
|
<div className="sm-label">권장보다 18분 부족</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="sleep-bar">
|
|
|
{h.sleep.stages.map((s) => (
|
|
|
<span key={s.label} style={{ width: s.pct + "%", background: s.tone === "muted" ? "var(--card-3)" : `var(--${s.tone})` }} />
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="sleep-legend">
|
|
|
{h.sleep.stages.map((s) => (
|
|
|
<span className="lg" key={s.label}><i style={{ background: s.tone === "muted" ? "var(--faint)" : `var(--${s.tone})` }} />{s.label} {s.pct}%</span>
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="sleep-week">
|
|
|
{h.sleep.week.map((v, i) => (
|
|
|
<div key={i} className={"sw" + (v >= 86 ? " peak" : "")} style={{ height: v + "%" }} title={v + "점"} />
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="sleep-week-labels">
|
|
|
{["월", "화", "수", "목", "금", "토", "일"].map((d) => <span key={d}>{d}</span>)}
|
|
|
</div>
|
|
|
<div className="sleep-note">{h.sleep.note}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="col-5">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-green"><I d="check" /></div>
|
|
|
<div className="c-h"><b>건강한 습관</b><span>리마인더 자동 설정됨</span></div>
|
|
|
<span className="c-act"><I d="plus" />추가</span>
|
|
|
</div>
|
|
|
<div className="habits">
|
|
|
{habits.map((hb) => {
|
|
|
const done = hb.done >= hb.total;
|
|
|
return (
|
|
|
<div className={"habit" + (done ? " done" : "")} key={hb.id}>
|
|
|
<div className="habit-top">
|
|
|
<div className={"h-ic bg-" + hb.tone}><I d={hb.icon} /></div>
|
|
|
<div className="h-tx"><b>{hb.title}</b><span>{hb.goal}</span></div>
|
|
|
<span className="habit-streak"><I d="flame" />{hb.streak}일</span>
|
|
|
</div>
|
|
|
<div className="habit-prog">
|
|
|
<div className="hp-bar"><i style={{ width: Math.min(100, (hb.done / hb.total) * 100) + "%", background: done ? "var(--green)" : `var(--${hb.tone})` }} /></div>
|
|
|
<span className="hp-n">{hb.done}/{hb.total}</span>
|
|
|
</div>
|
|
|
<div className="habit-foot">
|
|
|
<span className="habit-at"><I d="bell" />{hb.at}</span>
|
|
|
<button className={"habit-btn" + (done ? " done" : "")} onClick={() => onHabit(hb.id)}>
|
|
|
<I d={done ? "tick" : "plus"} w={done ? 3 : 1.9} />{done ? "완료" : "기록"}
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* ===================== 금융 모듈 ===================== */
|
|
|
function FinanceModule({ toast, paused, onPause }) {
|
|
|
const f = L.finance;
|
|
|
const b = f.budget;
|
|
|
return (
|
|
|
<>
|
|
|
<div className="mod-head">
|
|
|
<div className="mh-ic bg-blue"><I d="wallet" /></div>
|
|
|
<div className="mh-txt">
|
|
|
<h2>금융 & 소비</h2>
|
|
|
<div className="mh-sub">우리카드 · 토스 · 카카오뱅크 연동 · 6월 예산</div>
|
|
|
</div>
|
|
|
<div className="mh-act"><span className="c-act" onClick={() => toast("이번 달 가계부를 정리하고 있어요", "blue")}><I d="layers" />월간 정산</span></div>
|
|
|
</div>
|
|
|
|
|
|
<div className="card glassy" style={{ borderColor: "color-mix(in oklab, var(--blue) 22%, var(--line-2))" }}>
|
|
|
<div style={{ display: "flex", gap: 13, alignItems: "flex-start" }}>
|
|
|
<div style={{ width: 38, height: 38, borderRadius: 11, background: "var(--blue)", color: "#fff", display: "grid", placeItems: "center", flexShrink: 0, boxShadow: "0 6px 16px -6px var(--blue)" }}><I d="spark" /></div>
|
|
|
<div style={{ minWidth: 0 }}>
|
|
|
<div style={{ fontSize: 12, fontWeight: 700, color: "var(--blue)" }}>아리의 소비 코칭</div>
|
|
|
<p style={{ fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.6, margin: "6px 0 0" }} dangerouslySetInnerHTML={{ __html: f.coach }} />
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="lgrid">
|
|
|
<div className="col-5">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-blue"><I d="wallet" /></div>
|
|
|
<div className="c-h"><b>이달 예산</b><span>{b.daysLeft}일 남음</span></div>
|
|
|
</div>
|
|
|
<div className="budget-top">
|
|
|
<span className="bt-spent">{won(b.spent)}</span>
|
|
|
<span className="bt-of">/ {won(b.limit)}</span>
|
|
|
</div>
|
|
|
<span className="budget-delta t-green"><I d="trending" />지난달 대비 {Math.abs(b.deltaPct)}% 적게 썼어요</span>
|
|
|
<div className="budget-gauge"><i style={{ width: b.pct + "%" }} /></div>
|
|
|
<div className="budget-foot"><span>{b.pct}% 사용</span><span>남은 예산 {won(b.limit - b.spent)}</span></div>
|
|
|
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
|
|
|
<div className="insights">
|
|
|
{f.insights.map((ins, i) => (
|
|
|
<div className={"insight " + ins.kind} key={i}>
|
|
|
<div className="in-ic"><I d={ins.icon} /></div>
|
|
|
<div className="in-main"><b>{ins.title}</b><p>{ins.body}</p></div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="col-7">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-violet"><I d="layers" /></div>
|
|
|
<div className="c-h"><b>자동 분류된 지출</b><span>아리가 거래내역을 카테고리로 정리했어요</span></div>
|
|
|
</div>
|
|
|
<div className="cats">
|
|
|
{f.cats.map((c) => (
|
|
|
<div className="cat-row" key={c.name}>
|
|
|
<div className={"ca-ic bg-" + c.tone}><I d={c.icon} /></div>
|
|
|
<div className="ca-main">
|
|
|
<div className="ca-name">{c.name}{c.over && <span className="over-tag">예산 빠름</span>}</div>
|
|
|
<div className="ca-bar"><i style={{ width: c.pct * 2.4 + "%", background: c.over ? "var(--coral)" : `var(--${c.tone})` }} /></div>
|
|
|
</div>
|
|
|
<div className="ca-amt">{won(c.amt)}<small>{c.pct}%</small></div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="card" style={{ marginTop: 16 }}>
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-green"><I d="repeat" /></div>
|
|
|
<div className="c-h"><b>구독 결제 예정</b><span>결제일 전에 미리 알려드려요</span></div>
|
|
|
<span className="c-act"><I d="bell" />알림 켜짐</span>
|
|
|
</div>
|
|
|
<div className="subs">
|
|
|
{f.subs.map((s) => {
|
|
|
const isPaused = paused[s.name];
|
|
|
return (
|
|
|
<div className={"sub-row" + (s.inDays <= 3 && !isPaused ? " soon" : "")} key={s.name} style={isPaused ? { opacity: 0.55 } : null}>
|
|
|
<div className={"su-ic bg-" + s.tone}><I d={s.icon} /></div>
|
|
|
<div className="su-main"><b>{s.name}</b><span>{s.note}{isPaused ? " · 일시정지됨" : ""}</span></div>
|
|
|
<div className="su-when">
|
|
|
<div className="sw-date">{s.date}</div>
|
|
|
<div className={"sw-in" + (s.inDays > 3 ? " far" : "")}>{isPaused ? "정지" : s.inDays + "일 뒤"}</div>
|
|
|
</div>
|
|
|
<div className="su-amt">{won(s.amt)}</div>
|
|
|
<button className="c-act" style={{ flexShrink: 0 }} onClick={() => onPause(s.name)} aria-label="일시정지">
|
|
|
<I d={isPaused ? "refresh" : "more"} />
|
|
|
</button>
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* ===================== 지식 모듈 (제2의 뇌) ===================== */
|
|
|
function KnowledgeModule({ toast }) {
|
|
|
const k = L.knowledge;
|
|
|
const [q, setQ] = useState("");
|
|
|
const [answer, setAnswer] = useState(null);
|
|
|
const ask = (text) => {
|
|
|
if (!text.trim()) return;
|
|
|
setQ(text);
|
|
|
setAnswer({
|
|
|
q: text,
|
|
|
body: "저장하신 자료를 종합하면 — <b>검증 가능한 작은 단계로 분해</b>하고, 계획·실행·반성을 분리할 때 에이전트 신뢰성이 가장 크게 올라갑니다. ‘분기 회고’ 메모의 리텐션 +6%p 결과와도 연결돼요. 온보딩 자동화에 <b>오케스트레이터-워커 패턴</b>을 먼저 적용해보길 추천드려요.",
|
|
|
sources: ["AI 에이전트 설계 패턴", "분기 회고 메모", "재무 자동화 시스템"],
|
|
|
});
|
|
|
};
|
|
|
const filtered = useMemo(() => {
|
|
|
const s = q.trim().toLowerCase();
|
|
|
if (!s || answer) return k.items;
|
|
|
return k.items.filter((it) => (it.title + it.excerpt + it.tags.join(" ")).toLowerCase().includes(s));
|
|
|
}, [q, answer]);
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
<div className="mod-head">
|
|
|
<div className="mh-ic bg-violet"><I d="brain" /></div>
|
|
|
<div className="mh-txt">
|
|
|
<h2>지식 & 학습 · 제2의 뇌</h2>
|
|
|
<div className="mh-sub">{k.stats.items.toLocaleString()}개 저장 · 이번 주 +{k.stats.thisWeek} · {k.stats.collections}개 컬렉션</div>
|
|
|
</div>
|
|
|
<div className="mh-act"><span className="c-act" onClick={() => toast("새 항목을 스크랩할 준비가 됐어요", "violet")}><I d="plus" />스크랩</span></div>
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
<div className="kn-search">
|
|
|
<I d="spark" />
|
|
|
<input
|
|
|
value={q}
|
|
|
onChange={(e) => { setQ(e.target.value); if (answer) setAnswer(null); }}
|
|
|
onKeyDown={(e) => e.key === "Enter" && ask(q)}
|
|
|
placeholder="저장한 모든 것에 자연어로 물어보세요…"
|
|
|
/>
|
|
|
<button className="ks-go" onClick={() => ask(q)} aria-label="검색"><I d="arrow" /></button>
|
|
|
</div>
|
|
|
<div className="kn-suggest">
|
|
|
{k.suggested.map((s, i) => (
|
|
|
<button className="kn-chip" key={i} onClick={() => ask(s)}><I d="spark" />{s}</button>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{answer && (
|
|
|
<div className="kn-answer">
|
|
|
<div className="ka-ava"><I d="spark" /></div>
|
|
|
<div className="ka-main">
|
|
|
<div className="ka-q">"{answer.q}"</div>
|
|
|
<div className="ka-body" dangerouslySetInnerHTML={{ __html: answer.body }} />
|
|
|
<div className="ka-src">
|
|
|
{answer.sources.map((s, i) => <span className="src-chip" key={i}><I d="article" />{s}</span>)}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
<div className="lgrid">
|
|
|
<div className="col-8">
|
|
|
<div className="mod-head" style={{ marginBottom: 13 }}>
|
|
|
<div className="mh-txt"><h2 style={{ fontSize: 16 }}>{answer ? "관련 자료" : q.trim() ? "검색 결과" : "최근 스크랩"}</h2></div>
|
|
|
<div className="mh-act"><span className="c-act"><I d="refresh" />자동 태깅됨</span></div>
|
|
|
</div>
|
|
|
<div className="scraps">
|
|
|
{filtered.map((it) => (
|
|
|
<div className="scrap" key={it.id}>
|
|
|
<div className="scrap-top">
|
|
|
<div className={"sc-ic bg-" + it.tone}><I d={it.icon} /></div>
|
|
|
<div className="sc-h">
|
|
|
<b>{it.title}</b>
|
|
|
<div className="sc-meta">{it.src}<span className="dotsep" />{it.time}</div>
|
|
|
</div>
|
|
|
<span className={"scrap-type bg-" + it.tone}>{({ article: "아티클", note: "메모", idea: "아이디어", highlight: "하이라이트" })[it.type]}</span>
|
|
|
</div>
|
|
|
<p className="scrap-excerpt">{it.excerpt}</p>
|
|
|
<div className="scrap-ai">
|
|
|
<div className="sa-ic"><I d="spark" /></div>
|
|
|
<p dangerouslySetInnerHTML={{ __html: "<b>아리 — </b>" + it.ai }} />
|
|
|
</div>
|
|
|
<div className="scrap-foot">
|
|
|
{it.tags.map((t, i) => <span className={"tag" + (i === 0 ? " auto" : "")} key={i}>{t}</span>)}
|
|
|
<span className="sf-spacer" />
|
|
|
<button className="sf-act" onClick={() => toast("요약을 정리했어요", "violet")}><I d="layers" />요약</button>
|
|
|
<button className="sf-act" onClick={() => toast("열어보는 중…", "violet")}><I d="link" />열기</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
{filtered.length === 0 && <div className="kn-empty">‘{q}’에 해당하는 자료가 없어요. 다르게 물어보거나 새로 스크랩해 보세요.</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="col-4">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-violet"><I d="folder" /></div>
|
|
|
<div className="c-h"><b>컬렉션</b><span>주제별 자동 묶음</span></div>
|
|
|
</div>
|
|
|
<div className="collections">
|
|
|
{k.collections.map((c) => (
|
|
|
<button className="coll" key={c.name} onClick={() => toast(`'${c.name}' 컬렉션을 여는 중…`, "violet")}>
|
|
|
<div className={"co-ic bg-" + c.tone}><I d="layers" /></div>
|
|
|
<div className="co-tx"><b>{c.name}</b><span>{c.count}개</span></div>
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* ===================== 오늘 (통합) ===================== */
|
|
|
function TodayModule({ go, habits, toast }) {
|
|
|
const h = L.health, f = L.finance, k = L.knowledge;
|
|
|
const nextSub = f.subs[0];
|
|
|
const pendingHabits = habits.filter((x) => x.done < x.total);
|
|
|
return (
|
|
|
<>
|
|
|
<div className="coach-hero">
|
|
|
<div className="ch-ava"><I d="spark" /></div>
|
|
|
<div className="ch-main">
|
|
|
<div className="ch-eyebrow">아리 라이프 코치 · 오늘 아침</div>
|
|
|
<h1>오늘의 컨디션은 좋아요. 활동만 조금 더 챙기면 완벽해요 ☀️</h1>
|
|
|
<p>
|
|
|
어젯밤 <b>7시간 12분</b> 푹 주무셨고 수면 점수는 86점이에요. 다만 어제 <b>물 섭취가 절반</b>에 그쳤으니 오후에 리마인더를 넣어뒀어요.
|
|
|
이번 달 소비는 지난달보다 <b>8% 적고</b> 순항 중이지만, <b>이틀 뒤 Netflix·Spotify 결제</b>가 함께 빠져나가요.
|
|
|
저장하신 <b>‘AI 에이전트 설계’ 글</b>은 오후 미팅과 연결돼 요약해뒀어요.
|
|
|
</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="lgrid">
|
|
|
{/* 건강 스냅샷 */}
|
|
|
<div className="col-4">
|
|
|
<div className="card" style={{ cursor: "pointer" }} onClick={() => go("health")}>
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-coral"><I d="heart" /></div>
|
|
|
<div className="c-h"><b>건강 & 웰니스</b><span>활동·수면·습관</span></div>
|
|
|
<span className="c-act"><I d="arrow" /></span>
|
|
|
</div>
|
|
|
<div className="rings3">{h.rings.map((r) => <Ring key={r.label} r={r} />)}</div>
|
|
|
<div className="sleep-note" style={{ marginTop: 14 }}>물 마시기 <b>5/8잔</b> · 오후 3시 리마인더가 예정돼 있어요.</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* 금융 스냅샷 */}
|
|
|
<div className="col-4">
|
|
|
<div className="card" style={{ cursor: "pointer" }} onClick={() => go("finance")}>
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-blue"><I d="wallet" /></div>
|
|
|
<div className="c-h"><b>금융 & 소비</b><span>6월 예산</span></div>
|
|
|
<span className="c-act"><I d="arrow" /></span>
|
|
|
</div>
|
|
|
<div className="budget-top">
|
|
|
<span className="bt-spent" style={{ fontSize: 26 }}>{won(f.budget.spent)}</span>
|
|
|
<span className="bt-of">/ {won(f.budget.limit)}</span>
|
|
|
</div>
|
|
|
<span className="budget-delta t-green"><I d="trending" />지난달보다 8% 절약</span>
|
|
|
<div className="budget-gauge"><i style={{ width: f.budget.pct + "%" }} /></div>
|
|
|
<div className="sub-row soon" style={{ marginTop: 14 }}>
|
|
|
<div className={"su-ic bg-" + nextSub.tone}><I d={nextSub.icon} /></div>
|
|
|
<div className="su-main"><b>{nextSub.name}</b><span>다음 결제</span></div>
|
|
|
<div className="su-when"><div className="sw-date">{nextSub.date}</div><div className="sw-in">{nextSub.inDays}일 뒤</div></div>
|
|
|
<div className="su-amt">{won(nextSub.amt)}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* 지식 스냅샷 */}
|
|
|
<div className="col-4">
|
|
|
<div className="card" style={{ cursor: "pointer" }} onClick={() => go("knowledge")}>
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-violet"><I d="brain" /></div>
|
|
|
<div className="c-h"><b>지식 & 학습</b><span>제2의 뇌</span></div>
|
|
|
<span className="c-act"><I d="arrow" /></span>
|
|
|
</div>
|
|
|
<div className="kn-search" style={{ padding: "11px 14px", borderWidth: 1 }} onClick={(e) => { e.stopPropagation(); go("knowledge"); }}>
|
|
|
<I d="spark" />
|
|
|
<span style={{ flex: 1, fontSize: 13.5, color: "var(--faint)" }}>저장한 것에 물어보기…</span>
|
|
|
</div>
|
|
|
<div className="scraps" style={{ marginTop: 12 }}>
|
|
|
{k.items.slice(0, 2).map((it) => (
|
|
|
<div key={it.id} style={{ display: "flex", gap: 10, alignItems: "center", padding: "9px 0", borderTop: "1px solid var(--line)" }}>
|
|
|
<div className={"sc-ic bg-" + it.tone} style={{ width: 30, height: 30, borderRadius: 9, display: "grid", placeItems: "center", flexShrink: 0 }}><I d={it.icon} /></div>
|
|
|
<div style={{ minWidth: 0 }}>
|
|
|
<b style={{ fontSize: 12.5, fontWeight: 600, display: "block", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</b>
|
|
|
<span style={{ fontSize: 10.5, color: "var(--muted)" }}>{it.src} · {it.time}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* 오늘의 리마인더 */}
|
|
|
<div className="col-12">
|
|
|
<div className="card">
|
|
|
<div className="card-head">
|
|
|
<div className="c-ic bg-amber"><I d="bell" /></div>
|
|
|
<div className="c-h"><b>오늘의 리마인더</b><span>아리가 챙기고 있는 것들</span></div>
|
|
|
</div>
|
|
|
<div className="lgrid" style={{ gap: 11 }}>
|
|
|
{pendingHabits.map((hb) => (
|
|
|
<div className="col-4" key={hb.id}>
|
|
|
<div className="insight">
|
|
|
<div className={"in-ic bg-" + hb.tone}><I d={hb.icon} /></div>
|
|
|
<div className="in-main"><b>{hb.title}</b><p>{hb.at} · {hb.done}/{hb.total} 완료</p></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
<div className="col-4">
|
|
|
<div className="insight alert">
|
|
|
<div className="in-ic"><I d="repeat" /></div>
|
|
|
<div className="in-main"><b>구독 결제 임박</b><p>{nextSub.date} {nextSub.name} {won(nextSub.amt)} 결제 예정</p></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/* ===================== 앱 ===================== */
|
|
|
function App() {
|
|
|
const [theme, setTheme] = useState(() => load("theme", "light"));
|
|
|
const [mod, setMod] = useState(() => load("mod", "today"));
|
|
|
const [habits, setHabits] = useState(() => load("habits", L.health.habits));
|
|
|
const [paused, setPaused] = useState(() => load("paused", {}));
|
|
|
const [toasts, setToasts] = useState([]);
|
|
|
const shellRef = useRef(null);
|
|
|
const tid = useRef(0);
|
|
|
|
|
|
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]);
|
|
|
useEffect(() => save("mod", mod), [mod]);
|
|
|
useEffect(() => save("habits", habits), [habits]);
|
|
|
useEffect(() => save("paused", paused), [paused]);
|
|
|
useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, []);
|
|
|
|
|
|
const toast = (msg, tone) => {
|
|
|
const id = ++tid.current;
|
|
|
setToasts((t) => [...t, { id, msg, tone }]);
|
|
|
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2500);
|
|
|
};
|
|
|
const onHabit = (id) => {
|
|
|
setHabits((hs) => hs.map((h) => h.id === id && h.done < h.total ? { ...h, done: h.done + 1 } : h));
|
|
|
toast("습관을 기록했어요", "green");
|
|
|
};
|
|
|
const onPause = (name) => {
|
|
|
setPaused((p) => ({ ...p, [name]: !p[name] }));
|
|
|
toast(paused[name] ? "구독을 다시 활성화했어요" : "구독을 일시정지했어요", paused[name] ? "green" : "coral");
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<div className="mpage" ref={shellRef}>
|
|
|
{/* 상단바 */}
|
|
|
<header className="topbar">
|
|
|
<div className="brand">
|
|
|
<div className="brand-mark"><I d="spark" /></div>
|
|
|
<div className="brand-tag"><b>아리</b><span>AI LIFE OS</span></div>
|
|
|
</div>
|
|
|
<nav className="mainnav">
|
|
|
{MAIN.map((m) => (
|
|
|
<a key={m.id} href={m.href} className={m.id === "life" ? "active" : ""}>
|
|
|
<I d={m.icon} />{m.label}{m.badge && <span className="badge">{m.badge}</span>}
|
|
|
</a>
|
|
|
))}
|
|
|
</nav>
|
|
|
<div className="top-actions">
|
|
|
<button className="t-btn" aria-label="검색"><I d="search" /></button>
|
|
|
<button className="t-btn dot" aria-label="알림"><I d="bell" /></button>
|
|
|
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
|
|
|
<div className="t-ava">지</div>
|
|
|
</div>
|
|
|
</header>
|
|
|
|
|
|
<div className="lwork">
|
|
|
{/* 좌측: 모듈 + 연결 소스 */}
|
|
|
<aside className="subnav">
|
|
|
{MODULES.map((m) => (
|
|
|
<button key={m.id} className={"mod-row t-" + m.tone + (mod === m.id ? " on" : "")} onClick={() => setMod(m.id)}>
|
|
|
<div className="m-ic"><I d={m.icon} /></div>
|
|
|
<div className="m-txt"><b>{m.label}</b><span>{m.sub}</span></div>
|
|
|
</button>
|
|
|
))}
|
|
|
|
|
|
<div className="sn-label">연결된 데이터</div>
|
|
|
<div className="src-list">
|
|
|
{[...L.sources.health, ...L.sources.finance.slice(0, 2), ...L.sources.knowledge.slice(0, 2)].map((s) => (
|
|
|
<button className="src-row" key={s.id} onClick={() => toast(s.on ? `${s.name} 동기화 중…` : `${s.name} 연결 화면을 여는 중…`, s.tone)}>
|
|
|
<span className={"src-dot" + (s.on ? "" : " off")} style={{ background: `var(--${s.tone})` }} />
|
|
|
<div className="src-info"><b>{s.name}</b><span>{s.kind}</span></div>
|
|
|
<span className={"src-stat" + (s.on ? "" : " off")}>{s.on ? "연결됨" : "연결"}</span>
|
|
|
</button>
|
|
|
))}
|
|
|
<button className="src-add" onClick={() => toast("데이터 소스 연결 화면을 여는 중…", "blue")}><I d="plus" />소스 연결하기</button>
|
|
|
</div>
|
|
|
|
|
|
<div className="sn-foot">
|
|
|
<div className="av-lg">지</div>
|
|
|
<div className="txt"><b>지우님</b><span>Pro 플랜 · 9개 소스</span></div>
|
|
|
</div>
|
|
|
</aside>
|
|
|
|
|
|
{/* 본문 */}
|
|
|
<main className="lmain">
|
|
|
{mod === "today" && <TodayModule go={setMod} habits={habits} toast={toast} />}
|
|
|
{mod === "health" && <HealthModule habits={habits} onHabit={onHabit} toast={toast} />}
|
|
|
{mod === "finance" && <FinanceModule toast={toast} paused={paused} onPause={onPause} />}
|
|
|
{mod === "knowledge" && <KnowledgeModule toast={toast} />}
|
|
|
</main>
|
|
|
</div>
|
|
|
|
|
|
<div className="toast-wrap">
|
|
|
{toasts.map((t) => (
|
|
|
<div className="toast" key={t.id}>
|
|
|
<span className="t-dot" style={{ background: `var(--${t.tone || "blue"})` }} />
|
|
|
{t.msg}
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|